임동욱 임동욱 08-11
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
+++ @fox/dev-test/dev-test-page.tsx
@@ -4,9 +4,10 @@
 import { cx } from "../core/utils";
 import { COMPONENT_EXAMPLES } from "./component-registry";
 import { ComponentEmpty, ComponentView } from "./component-view";
+import type { CssSection } from "./read-css-tokens";
 import { ThemeSwitch } from "./theme-switch";
-import { TOKEN_MANIFEST } from "./token-manifest";
 import { TokenView } from "./token-view";
+import { useCssTokens } from "./use-css-tokens";
 import styles from "./dev-test.module.scss";
 
 // 선택 상태는 URL 해시가 SSOT다 — 새로고침하거나 링크를 공유해도 보던 섹션이 유지되고,
@@ -47,10 +48,10 @@
  * Responsive(PC/Mobile), 그리고 단일 모드인 Size·Theme·Primitive. 이 화면의 목적이
  * Figma 변수 페이지와 1:1로 대조하는 것이라, 우리 CSS 그룹이 아니라 원본 구획이 기준이다.
  */
-function groupedSections() {
-  const groups: { category: string | null; sections: typeof TOKEN_MANIFEST }[] = [];
+function groupedSections(sections: CssSection[]) {
+  const groups: { category: string | null; sections: CssSection[] }[] = [];
 
-  for (const section of TOKEN_MANIFEST) {
+  for (const section of sections) {
     const last = groups[groups.length - 1];
     if (last && last.category === section.category && section.category !== null) {
       last.sections.push(section);
@@ -64,9 +65,19 @@
 
 export function DevTestPage() {
   const selection = useSelection();
-  const totalTokens = TOKEN_MANIFEST.reduce((sum, section) => sum + section.tokens.length, 0);
-  const current = selection ?? { kind: "token", id: TOKEN_MANIFEST[0]?.id ?? "" };
-  const activeSection = TOKEN_MANIFEST.find((section) => section.id === current.id);
+  const sections = useCssTokens();
+
+  if (!sections) {
+    return (
+      <div className={styles.shell}>
+        <p className={styles.loading}>스타일시트를 읽는 중…</p>
+      </div>
+    );
+  }
+
+  const totalTokens = sections.reduce((sum, section) => sum + section.tokens.length, 0);
+  const current = selection ?? { kind: "token", id: sections[0]?.id ?? "" };
+  const activeSection = sections.find((section) => section.id === current.id);
 
   return (
     <div className={styles.shell}>
@@ -82,7 +93,7 @@
           <div className={styles.navGroup}>
             <h2 className={styles.navGroupTitle}>토큰 · {totalTokens}</h2>
 
-            {groupedSections().map((group) => (
+            {groupedSections(sections).map((group) => (
               <div key={group.category ?? group.sections[0].id} className={styles.navSubGroup}>
                 {group.category ? (
                   <h3 className={styles.navSubTitle}>{group.category}</h3>
 
@fox/dev-test/read-css-tokens.ts (added)
+++ @fox/dev-test/read-css-tokens.ts
@@ -0,0 +1,250 @@
+import { TOKEN_MANIFEST } from "./token-manifest";
+
+export interface CssToken {
+  /** `--fox-` 를 뗀 이름 */
+  name: string;
+  property: string;
+  /** 그 모드에서 선언된 원문. 참조면 `var(--fox-…)`, 아니면 값. */
+  declaration: string;
+  /** 참조면 가리키는 토큰 이름, 아니면 null */
+  alias: string | null;
+  /** Figma 변수 경로 — CSS에 없는 정보라 매니페스트에서 채운다. 없으면 null. */
+  figma: string | null;
+  /**
+   * 원본 Figma에서는 참조였지만 그 컬렉션이 제공되지 않아, 생성기가 리터럴로 떨궈
+   * CSS에는 참조 흔적이 남지 않은 경우. 이것도 CSS가 담지 못하는 정보다.
+   */
+  foreignAlias: string | null;
+}
+
+export interface CssSection {
+  id: string;
+  label: string;
+  category: string | null;
+  /** 이 섹션의 값이 어디서 왔는지 */
+  source: string;
+  tokens: CssToken[];
+}
+
+const PREFIX = "--fox-";
+
+// CSS가 담지 못하는 정보(Figma 변수 경로, "미제공 컬렉션 참조였다"는 사실)만 매니페스트에서
+// 가져온다. 값 자체는 절대 여기서 읽지 않는다 — 값의 출처는 컴파일된 CSS다.
+const MANIFEST_INDEX = new Map<string, Map<string, (typeof TOKEN_MANIFEST)[number]["tokens"][number]>>();
+for (const section of TOKEN_MANIFEST) {
+  MANIFEST_INDEX.set(section.id, new Map(section.tokens.map((token) => [token.name, token])));
+}
+
+/** `var(--fox-x)` 형태면 가리키는 토큰 이름을 돌려준다. */
+function aliasOf(value: string): string | null {
+  const match = /^var\(\s*--fox-([a-z0-9-]+)\s*\)$/.exec(value.trim());
+  return match ? match[1] : null;
+}
+
+/**
+ * `var(--name, 내용)`에서 괄호 짝을 세어 `내용`만 잘라낸다.
+ * 내용 자체가 `var(--fox-…)`를 품고 있어 정규식으로는 안전하게 못 자른다.
+ */
+function extractFallback(text: string, marker: string): string | null {
+  const start = text.indexOf(marker);
+  if (start < 0) {
+    return null;
+  }
+
+  let depth = 1;
+  let i = start + marker.length;
+  for (; i < text.length && depth > 0; i += 1) {
+    if (text[i] === "(") depth += 1;
+    else if (text[i] === ")") depth -= 1;
+  }
+  return text.slice(start + marker.length, i - 1).trim();
+}
+
+/** 최상위 콤마 하나로 두 조각을 나눈다(괄호 안의 콤마는 무시). */
+function splitTopLevel(text: string): [string, string] | null {
+  let depth = 0;
+  for (let i = 0; i < text.length; i += 1) {
+    const ch = text[i];
+    if (ch === "(") depth += 1;
+    else if (ch === ")") depth -= 1;
+    else if (ch === "," && depth === 0) {
+      return [text.slice(0, i).trim(), text.slice(i + 1).trim()];
+    }
+  }
+  return null;
+}
+
+/**
+ * 라이트/다크 두 값을 분해한다.
+ *
+ * Lightning CSS가 `light-dark()`를 폴리필해
+ * `var(--lightningcss-light, A) var(--lightningcss-dark, B)`로 내보내므로 그 형태를 먼저 보고,
+ * 폴리필이 없는 설정(브라우저 타깃을 좁힌 경우)을 대비해 원형도 함께 처리한다.
+ */
+function splitLightDark(declaration: string): { light: string; dark: string } | null {
+  const light = extractFallback(declaration, "var(--lightningcss-light,");
+  const dark = extractFallback(declaration, "var(--lightningcss-dark,");
+  if (light !== null && dark !== null) {
+    return { light, dark };
+  }
+
+  if (declaration.startsWith("light-dark(")) {
+    const inner = extractFallback(declaration, "light-dark(");
+    const pair = inner === null ? null : splitTopLevel(inner);
+    if (pair) {
+      return { light: pair[0], dark: pair[1] };
+    }
+  }
+
+  return null;
+}
+
+function toToken(sectionId: string, name: string, declaration: string): CssToken {
+  const meta = MANIFEST_INDEX.get(sectionId)?.get(name);
+  const foreign = meta?.alias?.includes("미제공") ? meta.alias : null;
+
+  return {
+    name,
+    property: `${PREFIX}${name}`,
+    declaration,
+    alias: aliasOf(declaration),
+    figma: meta?.figma ?? null,
+    foreignAlias: foreign,
+  };
+}
+
+interface RawTokens {
+  /** `:root` 기본 선언 — 색상은 light-dark 형태, 반응형은 모바일 값 */
+  base: Map<string, string>;
+  /** `@media (min-width: …)` 안의 오버라이드 — PC 값 */
+  pc: Map<string, string>;
+}
+
+function readRaw(): RawTokens {
+  const base = new Map<string, string>();
+  const pc = new Map<string, string>();
+
+  const collect = (rule: CSSStyleRule, into: Map<string, string>) => {
+    if (rule.selectorText !== ":root") {
+      return;
+    }
+    for (const property of Array.from(rule.style)) {
+      if (property.startsWith(PREFIX) && !into.has(property)) {
+        into.set(property.slice(PREFIX.length), rule.style.getPropertyValue(property).trim());
+      }
+    }
+  };
+
+  for (const sheet of Array.from(document.styleSheets)) {
+    let rules: CSSRuleList;
+    try {
+      rules = sheet.cssRules;
+    } catch {
+      // 교차 출처 스타일시트 — 우리 토큰은 동일 출처이므로 건너뛴다.
+      continue;
+    }
+
+    for (const rule of Array.from(rules)) {
+      if (rule instanceof CSSStyleRule) {
+        collect(rule, base);
+      } else if (rule instanceof CSSMediaRule && rule.conditionText.includes("min-width")) {
+        // 반응형 오버라이드. `prefers-color-scheme` 블록은 조건에 min-width가 없어 걸러진다.
+        for (const inner of Array.from(rule.cssRules)) {
+          if (inner instanceof CSSStyleRule) {
+            collect(inner, pc);
+          }
+        }
+      }
+    }
+  }
+
+  return { base, pc };
+}
+
+const SIZE_PREFIX = /^(border|padding|gap|radius|icon|shadow|backdrop)-/;
+const PRIMITIVE_PREFIX = /^(primitive|font-family|font-weight|number)-/;
+
+/**
+ * 컴파일된 CSS만 읽어 섹션을 구성한다.
+ *
+ * **매니페스트가 아니라 실제 스타일시트가 값의 출처다.** 토큰 SCSS를 고치면(원칙적으로
+ * 자동 생성물이라 고치면 안 되지만) 이 화면에 즉시 반영되므로, 생성물과 화면이 어긋날
+ * 여지가 없다. CSS가 담지 못하는 Figma 변수 경로만 매니페스트에서 이름으로 가져온다.
+ *
+ * 선언 **원문**을 읽는 것이 요점이다 — 계산값을 읽으면 참조(`var(--fox-…)`)가 이미
+ * 풀려 버리고, 현재 테마·뷰포트 한쪽 값만 보인다. 원문에는 light/dark와 PC/모바일이
+ * 모두 남아 있어 현재 화면 상태와 무관하게 네 조합을 전부 볼 수 있다.
+ */
+export function readCssSections(): CssSection[] {
+  const { base, pc } = readRaw();
+
+  const colors: { name: string; light: string; dark: string }[] = [];
+  const responsive: string[] = [];
+  const size: string[] = [];
+  const theme: string[] = [];
+  const primitive: string[] = [];
+
+  for (const [name, declaration] of base) {
+    if (name.startsWith("color-")) {
+      const pair = splitLightDark(declaration);
+      colors.push({
+        name,
+        light: pair?.light ?? declaration,
+        dark: pair?.dark ?? declaration,
+      });
+      continue;
+    }
+    if (pc.has(name)) {
+      responsive.push(name);
+      continue;
+    }
+    if (SIZE_PREFIX.test(name)) {
+      size.push(name);
+      continue;
+    }
+    if (name.startsWith("theme-")) {
+      theme.push(name);
+      continue;
+    }
+    if (PRIMITIVE_PREFIX.test(name)) {
+      primitive.push(name);
+      continue;
+    }
+    // 분류에 없는 새 토큰도 버리지 않는다 — Size 뒤에 붙여 눈에 띄게 둔다.
+    size.push(name);
+  }
+
+  return [
+    {
+      id: "light",
+      label: "Light",
+      category: "Mode",
+      source: "컴파일된 CSS — light-dark()의 라이트 쪽",
+      tokens: colors.map((entry) => toToken("light", entry.name, entry.light)),
+    },
+    {
+      id: "dark",
+      label: "Dark",
+      category: "Mode",
+      source: "컴파일된 CSS — light-dark()의 다크 쪽",
+      tokens: colors.map((entry) => toToken("dark", entry.name, entry.dark)),
+    },
+    {
+      id: "pc",
+      label: "PC",
+      category: "Responsive",
+      source: "컴파일된 CSS — @media (min-width: 768px) 블록",
+      tokens: responsive.map((name) => toToken("pc", name, pc.get(name) ?? "")),
+    },
+    {
+      id: "mobile",
+      label: "Mobile",
+      category: "Responsive",
+      source: "컴파일된 CSS — :root 기본값(모바일 우선)",
+      tokens: responsive.map((name) => toToken("mobile", name, base.get(name) ?? "")),
+    },
+    { id: "size", label: "Size", category: null, source: "컴파일된 CSS — :root", tokens: size.map((n) => toToken("size", n, base.get(n) ?? "")) },
+    { id: "theme", label: "Theme", category: null, source: "컴파일된 CSS — :root", tokens: theme.map((n) => toToken("theme", n, base.get(n) ?? "")) },
+    { id: "primitive", label: "Primitive", category: null, source: "컴파일된 CSS — :root", tokens: primitive.map((n) => toToken("primitive", n, base.get(n) ?? "")) },
+  ];
+}
@fox/dev-test/token-view.tsx
--- @fox/dev-test/token-view.tsx
+++ @fox/dev-test/token-view.tsx
@@ -1,15 +1,17 @@
 "use client";
 
 import type { CSSProperties, ReactNode } from "react";
-import type { ManifestSection, ManifestToken } from "./token-manifest";
+import type { CssSection, CssToken } from "./read-css-tokens";
 import styles from "./dev-test.module.scss";
 
 /**
  * 미리보기 — 토큰 이름으로 무엇을 보여줄지 고른다.
  *
- * ⚠️ 값은 CSS 변수가 아니라 매니페스트의 **해석값**을 직접 넣는다. 변수를 쓰면 PC 섹션을
- * 좁은 창에서 볼 때 모바일 값이 그려지고, Light 섹션이 다크 테마에서 다크 색으로 그려진다 —
- * 이 화면은 "그 파일이 정의한 값"을 보여야 하므로 현재 테마·뷰포트에 흔들리면 안 된다.
+ * ⚠️ 값은 그 토큰의 CSS 변수가 아니라 **그 섹션의 선언 원문**을 넣는다. `var(--fox-그토큰)`을
+ * 쓰면 PC 섹션을 좁은 창에서 볼 때 모바일 값이 그려지고 Light 섹션이 다크 테마에서 다크 색으로
+ * 그려진다 — 이 화면은 각 모드가 정의한 값을 보여야 하므로 현재 테마·뷰포트에 흔들리면 안 된다.
+ * 원문이 참조(`var(--fox-primitive-…)`)인 경우는 그대로 넣어도 안전하다. 참조 대상인
+ * primitive·theme는 모드와 무관한 단일 값이기 때문이다.
  * 인라인 `style`을 쓰는 예외인 이유도 같다: 렌더할 토큰을 미리 알 수 없다.
  */
 const PREVIEWS: { match: RegExp; render: (value: string) => ReactNode }[] = [
@@ -106,27 +108,32 @@
   },
 ];
 
-function previewFor(token: ManifestToken): ReactNode {
-  if (token.type === "color") {
+function previewFor(token: CssToken): ReactNode {
+  if (isColor(token)) {
     // 색은 값 칸의 칩이 이미 보여준다.
     return null;
   }
   const hit = PREVIEWS.find((entry) => entry.match.test(token.name));
-  return hit ? hit.render(token.resolved) : null;
+  return hit ? hit.render(token.declaration) : null;
 }
 
-/** 참조 대상이 우리가 갖고 있지 않은 컬렉션일 때 생성기가 붙이는 표시. */
-function isForeign(alias: string | null): boolean {
-  return Boolean(alias?.includes("미제공"));
+/** 색 토큰 판별 — 이 세 접두사만 색이다(font-·number-는 아니다). */
+function isColor(token: CssToken): boolean {
+  return /^(color|primitive|theme)-/.test(token.name);
 }
 
-function chipStyle(token: ManifestToken): CSSProperties {
-  return { background: token.resolved };
+/**
+ * 칩 배경으로 선언 원문을 그대로 넣는다. 참조(`var(--fox-primitive-neutral-0)`)든
+ * 리터럴이든 CSS가 알아서 해석하고, 참조 대상인 primitive·theme는 모드와 무관한
+ * 값이라 Light 섹션은 라이트 색이, Dark 섹션은 다크 색이 정확히 그려진다.
+ */
+function chipStyle(token: CssToken): CSSProperties {
+  return { background: token.declaration };
 }
 
-export function TokenView({ section }: { section: ManifestSection }) {
+export function TokenView({ section }: { section: CssSection }) {
   const hasPreview = section.tokens.some((token) => previewFor(token) !== null);
-  const foreignCount = section.tokens.filter((token) => isForeign(token.alias)).length;
+  const foreignCount = section.tokens.filter((token) => token.foreignAlias).length;
 
   return (
     <section className={styles.section}>
@@ -134,10 +141,10 @@
         {section.label} · {section.tokens.length}
       </h2>
       <p className={styles.sectionNote}>
-        {section.file} — 파일에 값이 적혀 있으면 그 값을, 다른 토큰을 가리키면 참조 토큰을
-        표시합니다.
+        {section.source} — 값은 매니페스트가 아니라 실제 스타일시트에서 읽습니다. 참조면
+        가리키는 토큰을, 아니면 값을 표시합니다.
         {foreignCount > 0
-          ? ` 이 중 ${foreignCount}개는 제공되지 않은 컬렉션을 가리킵니다.`
+          ? ` 이 중 ${foreignCount}개는 원본 Figma에서 제공되지 않은 컬렉션을 가리켜 값으로 굳어졌습니다.`
           : ""}
       </p>
 
@@ -157,19 +164,25 @@
                 <span className={styles.tokenFigma}>{token.figma}</span>
               </td>
               <td className={styles.tdValue}>
-                {token.type === "color" ? (
+                {isColor(token) ? (
                   <span className={styles.colorChip} style={chipStyle(token)} />
                 ) : null}
-                {token.literal !== null ? (
-                  token.literal
-                ) : (
-                  <span
-                    className={isForeign(token.alias) ? styles.aliasForeign : styles.aliasRef}
-                    title={isForeign(token.alias) ? "제공된 파일에 없는 컬렉션" : "참조 토큰"}
-                  >
+                {token.alias !== null ? (
+                  <span className={styles.aliasRef} title="참조 토큰">
                     → {token.alias}
                   </span>
+                ) : (
+                  token.declaration
                 )}
+                {token.foreignAlias ? (
+                  <span
+                    className={styles.aliasForeign}
+                    title="원본 Figma에서는 참조였으나 해당 컬렉션이 제공되지 않아 값으로 굳어졌습니다"
+                  >
+                    {" "}
+                    ({token.foreignAlias})
+                  </span>
+                ) : null}
               </td>
               {hasPreview ? <td className={styles.tdDemo}>{previewFor(token)}</td> : null}
             </tr>
 
@fox/dev-test/use-css-tokens.ts (added)
+++ @fox/dev-test/use-css-tokens.ts
@@ -0,0 +1,28 @@
+import { useSyncExternalStore } from "react";
+import { readCssSections, type CssSection } from "./read-css-tokens";
+
+// 스타일시트는 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다
+// (effect에서 setState 하면 렌더가 연쇄된다).
+//
+// 선언 **원문**을 읽으므로 결과가 현재 테마·뷰포트에 영향받지 않는다 — 한 번 읽어
+// 캐시하면 그만이다. `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로
+// 매번 새 배열을 돌려주면 무한 루프가 된다.
+let cached: CssSection[] | null = null;
+
+function subscribe(): () => void {
+  return () => {};
+}
+
+function getSnapshot(): CssSection[] | null {
+  cached ??= readCssSections();
+  return cached;
+}
+
+function getServerSnapshot(): CssSection[] | null {
+  return null;
+}
+
+/** 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 CSS에서 읽은 섹션 목록. */
+export function useCssTokens(): CssSection[] | null {
+  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
Add a comment
List