refactor: 토큰 값을 매니페스트가 아니라 컴파일된 CSS에서 읽는다
SCSS를 고치면 화면에 즉시 반영되게 하려는 것이다. 매니페스트를 값의 출처로 두면 생성물과 화면이 어긋날 수 있는데, 스타일시트를 직접 읽으면 그 여지가 없다. 핵심은 계산값이 아니라 **선언 원문**을 읽는 것이다. 계산값은 참조가 이미 풀려 있고 현재 테마·뷰포트 한쪽만 보이지만, 원문에는 참조(var(--fox-…))와 light/dark 양쪽, PC/모바일 양쪽이 모두 남아 있다. 덕분에 다크 테마에서도 Light 섹션이, 넓은 창에서도 Mobile 섹션이 정확한 값을 보여준다. light-dark()는 Lightning CSS가 폴리필한 형태로 나오므로 그 형태를 먼저 파싱하고, 폴리필이 없는 설정을 대비해 원형도 함께 처리한다. 괄호 짝을 세어 자르는 이유는 값 안에 var()가 중첩돼 정규식으로는 안전하게 나눌 수 없기 때문이다. 매니페스트는 CSS가 담지 못하는 두 가지만 제공한다 — Figma 변수 경로, 그리고 "원본에선 참조였으나 컬렉션이 없어 값으로 굳은 것"(chart-alt 18개) 표시. Co-Authored-By: Claude Opus 5
@9e6622078132033fff1f59b5730c43b8ddbe2c4a
--- @fox/dev-test/dev-test-page.tsx
+++ @fox/dev-test/dev-test-page.tsx
... | ... | @@ -4,9 +4,10 @@ |
| 4 | 4 |
import { cx } from "../core/utils";
|
| 5 | 5 |
import { COMPONENT_EXAMPLES } from "./component-registry";
|
| 6 | 6 |
import { ComponentEmpty, ComponentView } from "./component-view";
|
| 7 |
+import type { CssSection } from "./read-css-tokens";
|
|
| 7 | 8 |
import { ThemeSwitch } from "./theme-switch";
|
| 8 |
-import { TOKEN_MANIFEST } from "./token-manifest";
|
|
| 9 | 9 |
import { TokenView } from "./token-view";
|
| 10 |
+import { useCssTokens } from "./use-css-tokens";
|
|
| 10 | 11 |
import styles from "./dev-test.module.scss"; |
| 11 | 12 |
|
| 12 | 13 |
// 선택 상태는 URL 해시가 SSOT다 — 새로고침하거나 링크를 공유해도 보던 섹션이 유지되고, |
... | ... | @@ -47,10 +48,10 @@ |
| 47 | 48 |
* Responsive(PC/Mobile), 그리고 단일 모드인 Size·Theme·Primitive. 이 화면의 목적이 |
| 48 | 49 |
* Figma 변수 페이지와 1:1로 대조하는 것이라, 우리 CSS 그룹이 아니라 원본 구획이 기준이다. |
| 49 | 50 |
*/ |
| 50 |
-function groupedSections() {
|
|
| 51 |
- const groups: { category: string | null; sections: typeof TOKEN_MANIFEST }[] = [];
|
|
| 51 |
+function groupedSections(sections: CssSection[]) {
|
|
| 52 |
+ const groups: { category: string | null; sections: CssSection[] }[] = [];
|
|
| 52 | 53 |
|
| 53 |
- for (const section of TOKEN_MANIFEST) {
|
|
| 54 |
+ for (const section of sections) {
|
|
| 54 | 55 |
const last = groups[groups.length - 1]; |
| 55 | 56 |
if (last && last.category === section.category && section.category !== null) {
|
| 56 | 57 |
last.sections.push(section); |
... | ... | @@ -64,9 +65,19 @@ |
| 64 | 65 |
|
| 65 | 66 |
export function DevTestPage() {
|
| 66 | 67 |
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); |
|
| 68 |
+ const sections = useCssTokens(); |
|
| 69 |
+ |
|
| 70 |
+ if (!sections) {
|
|
| 71 |
+ return ( |
|
| 72 |
+ <div className={styles.shell}>
|
|
| 73 |
+ <p className={styles.loading}>스타일시트를 읽는 중…</p>
|
|
| 74 |
+ </div> |
|
| 75 |
+ ); |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ const totalTokens = sections.reduce((sum, section) => sum + section.tokens.length, 0); |
|
| 79 |
+ const current = selection ?? { kind: "token", id: sections[0]?.id ?? "" };
|
|
| 80 |
+ const activeSection = sections.find((section) => section.id === current.id); |
|
| 70 | 81 |
|
| 71 | 82 |
return ( |
| 72 | 83 |
<div className={styles.shell}>
|
... | ... | @@ -82,7 +93,7 @@ |
| 82 | 93 |
<div className={styles.navGroup}>
|
| 83 | 94 |
<h2 className={styles.navGroupTitle}>토큰 · {totalTokens}</h2>
|
| 84 | 95 |
|
| 85 |
- {groupedSections().map((group) => (
|
|
| 96 |
+ {groupedSections(sections).map((group) => (
|
|
| 86 | 97 |
<div key={group.category ?? group.sections[0].id} className={styles.navSubGroup}>
|
| 87 | 98 |
{group.category ? (
|
| 88 | 99 |
<h3 className={styles.navSubTitle}>{group.category}</h3>
|
+++ @fox/dev-test/read-css-tokens.ts
... | ... | @@ -0,0 +1,250 @@ |
| 1 | +import { TOKEN_MANIFEST } from "./token-manifest"; | |
| 2 | + | |
| 3 | +export interface CssToken { | |
| 4 | + /** `--fox-` 를 뗀 이름 */ | |
| 5 | + name: string; | |
| 6 | + property: string; | |
| 7 | + /** 그 모드에서 선언된 원문. 참조면 `var(--fox-…)`, 아니면 값. */ | |
| 8 | + declaration: string; | |
| 9 | + /** 참조면 가리키는 토큰 이름, 아니면 null */ | |
| 10 | + alias: string | null; | |
| 11 | + /** Figma 변수 경로 — CSS에 없는 정보라 매니페스트에서 채운다. 없으면 null. */ | |
| 12 | + figma: string | null; | |
| 13 | + /** | |
| 14 | + * 원본 Figma에서는 참조였지만 그 컬렉션이 제공되지 않아, 생성기가 리터럴로 떨궈 | |
| 15 | + * CSS에는 참조 흔적이 남지 않은 경우. 이것도 CSS가 담지 못하는 정보다. | |
| 16 | + */ | |
| 17 | + foreignAlias: string | null; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface CssSection { | |
| 21 | + id: string; | |
| 22 | + label: string; | |
| 23 | + category: string | null; | |
| 24 | + /** 이 섹션의 값이 어디서 왔는지 */ | |
| 25 | + source: string; | |
| 26 | + tokens: CssToken[]; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const PREFIX = "--fox-"; | |
| 30 | + | |
| 31 | +// CSS가 담지 못하는 정보(Figma 변수 경로, "미제공 컬렉션 참조였다"는 사실)만 매니페스트에서 | |
| 32 | +// 가져온다. 값 자체는 절대 여기서 읽지 않는다 — 값의 출처는 컴파일된 CSS다. | |
| 33 | +const MANIFEST_INDEX = new Map<string, Map<string, (typeof TOKEN_MANIFEST)[number]["tokens"][number]>>(); | |
| 34 | +for (const section of TOKEN_MANIFEST) { | |
| 35 | + MANIFEST_INDEX.set(section.id, new Map(section.tokens.map((token) => [token.name, token]))); | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** `var(--fox-x)` 형태면 가리키는 토큰 이름을 돌려준다. */ | |
| 39 | +function aliasOf(value: string): string | null { | |
| 40 | + const match = /^var\(\s*--fox-([a-z0-9-]+)\s*\)$/.exec(value.trim()); | |
| 41 | + return match ? match[1] : null; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * `var(--name, 내용)`에서 괄호 짝을 세어 `내용`만 잘라낸다. | |
| 46 | + * 내용 자체가 `var(--fox-…)`를 품고 있어 정규식으로는 안전하게 못 자른다. | |
| 47 | + */ | |
| 48 | +function extractFallback(text: string, marker: string): string | null { | |
| 49 | + const start = text.indexOf(marker); | |
| 50 | + if (start < 0) { | |
| 51 | + return null; | |
| 52 | + } | |
| 53 | + | |
| 54 | + let depth = 1; | |
| 55 | + let i = start + marker.length; | |
| 56 | + for (; i < text.length && depth > 0; i += 1) { | |
| 57 | + if (text[i] === "(") depth += 1; | |
| 58 | + else if (text[i] === ")") depth -= 1; | |
| 59 | + } | |
| 60 | + return text.slice(start + marker.length, i - 1).trim(); | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** 최상위 콤마 하나로 두 조각을 나눈다(괄호 안의 콤마는 무시). */ | |
| 64 | +function splitTopLevel(text: string): [string, string] | null { | |
| 65 | + let depth = 0; | |
| 66 | + for (let i = 0; i < text.length; i += 1) { | |
| 67 | + const ch = text[i]; | |
| 68 | + if (ch === "(") depth += 1; | |
| 69 | + else if (ch === ")") depth -= 1; | |
| 70 | + else if (ch === "," && depth === 0) { | |
| 71 | + return [text.slice(0, i).trim(), text.slice(i + 1).trim()]; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + return null; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** | |
| 78 | + * 라이트/다크 두 값을 분해한다. | |
| 79 | + * | |
| 80 | + * Lightning CSS가 `light-dark()`를 폴리필해 | |
| 81 | + * `var(--lightningcss-light, A) var(--lightningcss-dark, B)`로 내보내므로 그 형태를 먼저 보고, | |
| 82 | + * 폴리필이 없는 설정(브라우저 타깃을 좁힌 경우)을 대비해 원형도 함께 처리한다. | |
| 83 | + */ | |
| 84 | +function splitLightDark(declaration: string): { light: string; dark: string } | null { | |
| 85 | + const light = extractFallback(declaration, "var(--lightningcss-light,"); | |
| 86 | + const dark = extractFallback(declaration, "var(--lightningcss-dark,"); | |
| 87 | + if (light !== null && dark !== null) { | |
| 88 | + return { light, dark }; | |
| 89 | + } | |
| 90 | + | |
| 91 | + if (declaration.startsWith("light-dark(")) { | |
| 92 | + const inner = extractFallback(declaration, "light-dark("); | |
| 93 | + const pair = inner === null ? null : splitTopLevel(inner); | |
| 94 | + if (pair) { | |
| 95 | + return { light: pair[0], dark: pair[1] }; | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + return null; | |
| 100 | +} | |
| 101 | + | |
| 102 | +function toToken(sectionId: string, name: string, declaration: string): CssToken { | |
| 103 | + const meta = MANIFEST_INDEX.get(sectionId)?.get(name); | |
| 104 | + const foreign = meta?.alias?.includes("미제공") ? meta.alias : null; | |
| 105 | + | |
| 106 | + return { | |
| 107 | + name, | |
| 108 | + property: `${PREFIX}${name}`, | |
| 109 | + declaration, | |
| 110 | + alias: aliasOf(declaration), | |
| 111 | + figma: meta?.figma ?? null, | |
| 112 | + foreignAlias: foreign, | |
| 113 | + }; | |
| 114 | +} | |
| 115 | + | |
| 116 | +interface RawTokens { | |
| 117 | + /** `:root` 기본 선언 — 색상은 light-dark 형태, 반응형은 모바일 값 */ | |
| 118 | + base: Map<string, string>; | |
| 119 | + /** `@media (min-width: …)` 안의 오버라이드 — PC 값 */ | |
| 120 | + pc: Map<string, string>; | |
| 121 | +} | |
| 122 | + | |
| 123 | +function readRaw(): RawTokens { | |
| 124 | + const base = new Map<string, string>(); | |
| 125 | + const pc = new Map<string, string>(); | |
| 126 | + | |
| 127 | + const collect = (rule: CSSStyleRule, into: Map<string, string>) => { | |
| 128 | + if (rule.selectorText !== ":root") { | |
| 129 | + return; | |
| 130 | + } | |
| 131 | + for (const property of Array.from(rule.style)) { | |
| 132 | + if (property.startsWith(PREFIX) && !into.has(property)) { | |
| 133 | + into.set(property.slice(PREFIX.length), rule.style.getPropertyValue(property).trim()); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + }; | |
| 137 | + | |
| 138 | + for (const sheet of Array.from(document.styleSheets)) { | |
| 139 | + let rules: CSSRuleList; | |
| 140 | + try { | |
| 141 | + rules = sheet.cssRules; | |
| 142 | + } catch { | |
| 143 | + // 교차 출처 스타일시트 — 우리 토큰은 동일 출처이므로 건너뛴다. | |
| 144 | + continue; | |
| 145 | + } | |
| 146 | + | |
| 147 | + for (const rule of Array.from(rules)) { | |
| 148 | + if (rule instanceof CSSStyleRule) { | |
| 149 | + collect(rule, base); | |
| 150 | + } else if (rule instanceof CSSMediaRule && rule.conditionText.includes("min-width")) { | |
| 151 | + // 반응형 오버라이드. `prefers-color-scheme` 블록은 조건에 min-width가 없어 걸러진다. | |
| 152 | + for (const inner of Array.from(rule.cssRules)) { | |
| 153 | + if (inner instanceof CSSStyleRule) { | |
| 154 | + collect(inner, pc); | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + } | |
| 160 | + | |
| 161 | + return { base, pc }; | |
| 162 | +} | |
| 163 | + | |
| 164 | +const SIZE_PREFIX = /^(border|padding|gap|radius|icon|shadow|backdrop)-/; | |
| 165 | +const PRIMITIVE_PREFIX = /^(primitive|font-family|font-weight|number)-/; | |
| 166 | + | |
| 167 | +/** | |
| 168 | + * 컴파일된 CSS만 읽어 섹션을 구성한다. | |
| 169 | + * | |
| 170 | + * **매니페스트가 아니라 실제 스타일시트가 값의 출처다.** 토큰 SCSS를 고치면(원칙적으로 | |
| 171 | + * 자동 생성물이라 고치면 안 되지만) 이 화면에 즉시 반영되므로, 생성물과 화면이 어긋날 | |
| 172 | + * 여지가 없다. CSS가 담지 못하는 Figma 변수 경로만 매니페스트에서 이름으로 가져온다. | |
| 173 | + * | |
| 174 | + * 선언 **원문**을 읽는 것이 요점이다 — 계산값을 읽으면 참조(`var(--fox-…)`)가 이미 | |
| 175 | + * 풀려 버리고, 현재 테마·뷰포트 한쪽 값만 보인다. 원문에는 light/dark와 PC/모바일이 | |
| 176 | + * 모두 남아 있어 현재 화면 상태와 무관하게 네 조합을 전부 볼 수 있다. | |
| 177 | + */ | |
| 178 | +export function readCssSections(): CssSection[] { | |
| 179 | + const { base, pc } = readRaw(); | |
| 180 | + | |
| 181 | + const colors: { name: string; light: string; dark: string }[] = []; | |
| 182 | + const responsive: string[] = []; | |
| 183 | + const size: string[] = []; | |
| 184 | + const theme: string[] = []; | |
| 185 | + const primitive: string[] = []; | |
| 186 | + | |
| 187 | + for (const [name, declaration] of base) { | |
| 188 | + if (name.startsWith("color-")) { | |
| 189 | + const pair = splitLightDark(declaration); | |
| 190 | + colors.push({ | |
| 191 | + name, | |
| 192 | + light: pair?.light ?? declaration, | |
| 193 | + dark: pair?.dark ?? declaration, | |
| 194 | + }); | |
| 195 | + continue; | |
| 196 | + } | |
| 197 | + if (pc.has(name)) { | |
| 198 | + responsive.push(name); | |
| 199 | + continue; | |
| 200 | + } | |
| 201 | + if (SIZE_PREFIX.test(name)) { | |
| 202 | + size.push(name); | |
| 203 | + continue; | |
| 204 | + } | |
| 205 | + if (name.startsWith("theme-")) { | |
| 206 | + theme.push(name); | |
| 207 | + continue; | |
| 208 | + } | |
| 209 | + if (PRIMITIVE_PREFIX.test(name)) { | |
| 210 | + primitive.push(name); | |
| 211 | + continue; | |
| 212 | + } | |
| 213 | + // 분류에 없는 새 토큰도 버리지 않는다 — Size 뒤에 붙여 눈에 띄게 둔다. | |
| 214 | + size.push(name); | |
| 215 | + } | |
| 216 | + | |
| 217 | + return [ | |
| 218 | + { | |
| 219 | + id: "light", | |
| 220 | + label: "Light", | |
| 221 | + category: "Mode", | |
| 222 | + source: "컴파일된 CSS — light-dark()의 라이트 쪽", | |
| 223 | + tokens: colors.map((entry) => toToken("light", entry.name, entry.light)), | |
| 224 | + }, | |
| 225 | + { | |
| 226 | + id: "dark", | |
| 227 | + label: "Dark", | |
| 228 | + category: "Mode", | |
| 229 | + source: "컴파일된 CSS — light-dark()의 다크 쪽", | |
| 230 | + tokens: colors.map((entry) => toToken("dark", entry.name, entry.dark)), | |
| 231 | + }, | |
| 232 | + { | |
| 233 | + id: "pc", | |
| 234 | + label: "PC", | |
| 235 | + category: "Responsive", | |
| 236 | + source: "컴파일된 CSS — @media (min-width: 768px) 블록", | |
| 237 | + tokens: responsive.map((name) => toToken("pc", name, pc.get(name) ?? "")), | |
| 238 | + }, | |
| 239 | + { | |
| 240 | + id: "mobile", | |
| 241 | + label: "Mobile", | |
| 242 | + category: "Responsive", | |
| 243 | + source: "컴파일된 CSS — :root 기본값(모바일 우선)", | |
| 244 | + tokens: responsive.map((name) => toToken("mobile", name, base.get(name) ?? "")), | |
| 245 | + }, | |
| 246 | + { id: "size", label: "Size", category: null, source: "컴파일된 CSS — :root", tokens: size.map((n) => toToken("size", n, base.get(n) ?? "")) }, | |
| 247 | + { id: "theme", label: "Theme", category: null, source: "컴파일된 CSS — :root", tokens: theme.map((n) => toToken("theme", n, base.get(n) ?? "")) }, | |
| 248 | + { id: "primitive", label: "Primitive", category: null, source: "컴파일된 CSS — :root", tokens: primitive.map((n) => toToken("primitive", n, base.get(n) ?? "")) }, | |
| 249 | + ]; | |
| 250 | +} |
--- @fox/dev-test/token-view.tsx
+++ @fox/dev-test/token-view.tsx
... | ... | @@ -1,15 +1,17 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import type { CSSProperties, ReactNode } from "react";
|
| 4 |
-import type { ManifestSection, ManifestToken } from "./token-manifest";
|
|
| 4 |
+import type { CssSection, CssToken } from "./read-css-tokens";
|
|
| 5 | 5 |
import styles from "./dev-test.module.scss"; |
| 6 | 6 |
|
| 7 | 7 |
/** |
| 8 | 8 |
* 미리보기 — 토큰 이름으로 무엇을 보여줄지 고른다. |
| 9 | 9 |
* |
| 10 |
- * ⚠️ 값은 CSS 변수가 아니라 매니페스트의 **해석값**을 직접 넣는다. 변수를 쓰면 PC 섹션을 |
|
| 11 |
- * 좁은 창에서 볼 때 모바일 값이 그려지고, Light 섹션이 다크 테마에서 다크 색으로 그려진다 — |
|
| 12 |
- * 이 화면은 "그 파일이 정의한 값"을 보여야 하므로 현재 테마·뷰포트에 흔들리면 안 된다. |
|
| 10 |
+ * ⚠️ 값은 그 토큰의 CSS 변수가 아니라 **그 섹션의 선언 원문**을 넣는다. `var(--fox-그토큰)`을 |
|
| 11 |
+ * 쓰면 PC 섹션을 좁은 창에서 볼 때 모바일 값이 그려지고 Light 섹션이 다크 테마에서 다크 색으로 |
|
| 12 |
+ * 그려진다 — 이 화면은 각 모드가 정의한 값을 보여야 하므로 현재 테마·뷰포트에 흔들리면 안 된다. |
|
| 13 |
+ * 원문이 참조(`var(--fox-primitive-…)`)인 경우는 그대로 넣어도 안전하다. 참조 대상인 |
|
| 14 |
+ * primitive·theme는 모드와 무관한 단일 값이기 때문이다. |
|
| 13 | 15 |
* 인라인 `style`을 쓰는 예외인 이유도 같다: 렌더할 토큰을 미리 알 수 없다. |
| 14 | 16 |
*/ |
| 15 | 17 |
const PREVIEWS: { match: RegExp; render: (value: string) => ReactNode }[] = [
|
... | ... | @@ -106,27 +108,32 @@ |
| 106 | 108 |
}, |
| 107 | 109 |
]; |
| 108 | 110 |
|
| 109 |
-function previewFor(token: ManifestToken): ReactNode {
|
|
| 110 |
- if (token.type === "color") {
|
|
| 111 |
+function previewFor(token: CssToken): ReactNode {
|
|
| 112 |
+ if (isColor(token)) {
|
|
| 111 | 113 |
// 색은 값 칸의 칩이 이미 보여준다. |
| 112 | 114 |
return null; |
| 113 | 115 |
} |
| 114 | 116 |
const hit = PREVIEWS.find((entry) => entry.match.test(token.name)); |
| 115 |
- return hit ? hit.render(token.resolved) : null; |
|
| 117 |
+ return hit ? hit.render(token.declaration) : null; |
|
| 116 | 118 |
} |
| 117 | 119 |
|
| 118 |
-/** 참조 대상이 우리가 갖고 있지 않은 컬렉션일 때 생성기가 붙이는 표시. */ |
|
| 119 |
-function isForeign(alias: string | null): boolean {
|
|
| 120 |
- return Boolean(alias?.includes("미제공"));
|
|
| 120 |
+/** 색 토큰 판별 — 이 세 접두사만 색이다(font-·number-는 아니다). */ |
|
| 121 |
+function isColor(token: CssToken): boolean {
|
|
| 122 |
+ return /^(color|primitive|theme)-/.test(token.name); |
|
| 121 | 123 |
} |
| 122 | 124 |
|
| 123 |
-function chipStyle(token: ManifestToken): CSSProperties {
|
|
| 124 |
- return { background: token.resolved };
|
|
| 125 |
+/** |
|
| 126 |
+ * 칩 배경으로 선언 원문을 그대로 넣는다. 참조(`var(--fox-primitive-neutral-0)`)든 |
|
| 127 |
+ * 리터럴이든 CSS가 알아서 해석하고, 참조 대상인 primitive·theme는 모드와 무관한 |
|
| 128 |
+ * 값이라 Light 섹션은 라이트 색이, Dark 섹션은 다크 색이 정확히 그려진다. |
|
| 129 |
+ */ |
|
| 130 |
+function chipStyle(token: CssToken): CSSProperties {
|
|
| 131 |
+ return { background: token.declaration };
|
|
| 125 | 132 |
} |
| 126 | 133 |
|
| 127 |
-export function TokenView({ section }: { section: ManifestSection }) {
|
|
| 134 |
+export function TokenView({ section }: { section: CssSection }) {
|
|
| 128 | 135 |
const hasPreview = section.tokens.some((token) => previewFor(token) !== null); |
| 129 |
- const foreignCount = section.tokens.filter((token) => isForeign(token.alias)).length; |
|
| 136 |
+ const foreignCount = section.tokens.filter((token) => token.foreignAlias).length; |
|
| 130 | 137 |
|
| 131 | 138 |
return ( |
| 132 | 139 |
<section className={styles.section}>
|
... | ... | @@ -134,10 +141,10 @@ |
| 134 | 141 |
{section.label} · {section.tokens.length}
|
| 135 | 142 |
</h2> |
| 136 | 143 |
<p className={styles.sectionNote}>
|
| 137 |
- {section.file} — 파일에 값이 적혀 있으면 그 값을, 다른 토큰을 가리키면 참조 토큰을
|
|
| 138 |
- 표시합니다. |
|
| 144 |
+ {section.source} — 값은 매니페스트가 아니라 실제 스타일시트에서 읽습니다. 참조면
|
|
| 145 |
+ 가리키는 토큰을, 아니면 값을 표시합니다. |
|
| 139 | 146 |
{foreignCount > 0
|
| 140 |
- ? ` 이 중 ${foreignCount}개는 제공되지 않은 컬렉션을 가리킵니다.`
|
|
| 147 |
+ ? ` 이 중 ${foreignCount}개는 원본 Figma에서 제공되지 않은 컬렉션을 가리켜 값으로 굳어졌습니다.`
|
|
| 141 | 148 |
: ""} |
| 142 | 149 |
</p> |
| 143 | 150 |
|
... | ... | @@ -157,19 +164,25 @@ |
| 157 | 164 |
<span className={styles.tokenFigma}>{token.figma}</span>
|
| 158 | 165 |
</td> |
| 159 | 166 |
<td className={styles.tdValue}>
|
| 160 |
- {token.type === "color" ? (
|
|
| 167 |
+ {isColor(token) ? (
|
|
| 161 | 168 |
<span className={styles.colorChip} style={chipStyle(token)} />
|
| 162 | 169 |
) : 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 !== null ? (
|
|
| 171 |
+ <span className={styles.aliasRef} title="참조 토큰">
|
|
| 170 | 172 |
→ {token.alias}
|
| 171 | 173 |
</span> |
| 174 |
+ ) : ( |
|
| 175 |
+ token.declaration |
|
| 172 | 176 |
)} |
| 177 |
+ {token.foreignAlias ? (
|
|
| 178 |
+ <span |
|
| 179 |
+ className={styles.aliasForeign}
|
|
| 180 |
+ title="원본 Figma에서는 참조였으나 해당 컬렉션이 제공되지 않아 값으로 굳어졌습니다" |
|
| 181 |
+ > |
|
| 182 |
+ {" "}
|
|
| 183 |
+ ({token.foreignAlias})
|
|
| 184 |
+ </span> |
|
| 185 |
+ ) : null} |
|
| 173 | 186 |
</td> |
| 174 | 187 |
{hasPreview ? <td className={styles.tdDemo}>{previewFor(token)}</td> : null}
|
| 175 | 188 |
</tr> |
+++ @fox/dev-test/use-css-tokens.ts
... | ... | @@ -0,0 +1,28 @@ |
| 1 | +import { useSyncExternalStore } from "react"; | |
| 2 | +import { readCssSections, type CssSection } from "./read-css-tokens"; | |
| 3 | + | |
| 4 | +// 스타일시트는 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다 | |
| 5 | +// (effect에서 setState 하면 렌더가 연쇄된다). | |
| 6 | +// | |
| 7 | +// 선언 **원문**을 읽으므로 결과가 현재 테마·뷰포트에 영향받지 않는다 — 한 번 읽어 | |
| 8 | +// 캐시하면 그만이다. `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 | |
| 9 | +// 매번 새 배열을 돌려주면 무한 루프가 된다. | |
| 10 | +let cached: CssSection[] | null = null; | |
| 11 | + | |
| 12 | +function subscribe(): () => void { | |
| 13 | + return () => {}; | |
| 14 | +} | |
| 15 | + | |
| 16 | +function getSnapshot(): CssSection[] | null { | |
| 17 | + cached ??= readCssSections(); | |
| 18 | + return cached; | |
| 19 | +} | |
| 20 | + | |
| 21 | +function getServerSnapshot(): CssSection[] | null { | |
| 22 | + return null; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 CSS에서 읽은 섹션 목록. */ | |
| 26 | +export function useCssTokens(): CssSection[] | null { | |
| 27 | + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 28 | +} |
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?