임동욱 임동욱 08-11
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
+++ @fox/dev-test/dev-test-page.tsx
@@ -5,13 +5,13 @@
 import { COMPONENT_EXAMPLES } from "./component-registry";
 import { ComponentEmpty, ComponentView } from "./component-view";
 import { ThemeSwitch } from "./theme-switch";
-import { TOKEN_GROUPS, TokenView } from "./token-view";
-import { useTokenRegistry } from "./use-token-registry";
+import { TOKEN_MANIFEST } from "./token-manifest";
+import { TokenView } from "./token-view";
 import styles from "./dev-test.module.scss";
 
 // 선택 상태는 URL 해시가 SSOT다 — 새로고침하거나 링크를 공유해도 보던 섹션이 유지되고,
 // React state를 따로 두지 않으므로 둘이 어긋날 일이 없다.
-// 형식: `#token:color` / `#component:button`
+// 형식: `#token:light` / `#component:button`
 function subscribeHash(onChange: () => void): () => void {
   window.addEventListener("hashchange", onChange);
   return () => window.removeEventListener("hashchange", onChange);
@@ -42,27 +42,31 @@
   return COMPONENT_EXAMPLES.some((example) => example.id === id);
 }
 
-export function DevTestPage() {
-  const registry = useTokenRegistry();
-  const selection = useSelection();
+/**
+ * 사이드바 분류는 원본 JSON 파일 구조를 그대로 따른다 — Mode(Light/Dark),
+ * Responsive(PC/Mobile), 그리고 단일 모드인 Size·Theme·Primitive. 이 화면의 목적이
+ * Figma 변수 페이지와 1:1로 대조하는 것이라, 우리 CSS 그룹이 아니라 원본 구획이 기준이다.
+ */
+function groupedSections() {
+  const groups: { category: string | null; sections: typeof TOKEN_MANIFEST }[] = [];
 
-  if (!registry) {
-    return (
-      <div className={styles.shell}>
-        <p className={styles.loading}>토큰을 읽는 중…</p>
-      </div>
-    );
+  for (const section of TOKEN_MANIFEST) {
+    const last = groups[groups.length - 1];
+    if (last && last.category === section.category && section.category !== null) {
+      last.sections.push(section);
+    } else {
+      groups.push({ category: section.category, sections: [section] });
+    }
   }
 
-  // 사이드바에 뜰 토큰 그룹 — 정의된 순서를 우선하고, 목록에 없는 그룹은 뒤에 붙인다.
-  const known = TOKEN_GROUPS.filter((group) => registry[group.key]?.length);
-  const extra = Object.keys(registry)
-    .filter((key) => !TOKEN_GROUPS.some((group) => group.key === key))
-    .map((key) => ({ key, label: key }));
-  const tokenGroups = [...known, ...extra];
+  return groups;
+}
 
-  const current = selection ?? { kind: "token", id: tokenGroups[0]?.key ?? "" };
-  const totalTokens = Object.values(registry).reduce((sum, list) => sum + list.length, 0);
+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);
 
   return (
     <div className={styles.shell}>
@@ -77,23 +81,31 @@
         <nav className={styles.nav} aria-label="섹션">
           <div className={styles.navGroup}>
             <h2 className={styles.navGroupTitle}>토큰 · {totalTokens}</h2>
-            <ul className={styles.navList}>
-              {tokenGroups.map((group) => (
-                <li key={group.key}>
-                  <button
-                    type="button"
-                    className={styles.navItem}
-                    aria-current={
-                      current.kind === "token" && current.id === group.key ? "page" : undefined
-                    }
-                    onClick={() => select("token", group.key)}
-                  >
-                    <span>{group.label}</span>
-                    <span className={styles.navCount}>{registry[group.key].length}</span>
-                  </button>
-                </li>
-              ))}
-            </ul>
+
+            {groupedSections().map((group) => (
+              <div key={group.category ?? group.sections[0].id} className={styles.navSubGroup}>
+                {group.category ? (
+                  <h3 className={styles.navSubTitle}>{group.category}</h3>
+                ) : null}
+                <ul className={styles.navList}>
+                  {group.sections.map((section) => (
+                    <li key={section.id}>
+                      <button
+                        type="button"
+                        className={styles.navItem}
+                        aria-current={
+                          current.kind === "token" && current.id === section.id ? "page" : undefined
+                        }
+                        onClick={() => select("token", section.id)}
+                      >
+                        <span>{section.label}</span>
+                        <span className={styles.navCount}>{section.tokens.length}</span>
+                      </button>
+                    </li>
+                  ))}
+                </ul>
+              </div>
+            ))}
           </div>
 
           <div className={styles.navGroup}>
@@ -145,10 +157,10 @@
             const example = COMPONENT_EXAMPLES.find((entry) => entry.id === current.id);
             return example ? <ComponentView example={example} /> : <ComponentEmpty />;
           })()
-        ) : registry[current.id]?.length ? (
-          <TokenView group={current.id} tokens={registry[current.id]} />
+        ) : activeSection ? (
+          <TokenView section={activeSection} />
         ) : (
-          <p className={styles.loading}>선택한 토큰 그룹이 없습니다.</p>
+          <p className={styles.loading}>선택한 토큰 섹션이 없습니다.</p>
         )}
       </main>
     </div>
@fox/dev-test/dev-test.module.scss
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
@@ -107,6 +107,22 @@
   letter-spacing: 0.04em;
 }
 
+// Mode / Responsive 처럼 하위 항목을 묶는 분류.
+.navSubGroup {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(1);
+  margin-block-start: fox.gap(2);
+}
+
+.navSubTitle {
+  padding-inline: fox.padding(3);
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-xsm);
+  font-weight: fox.font-weight(medium);
+  opacity: 0.8;
+}
+
 .navList {
   display: flex;
   flex-direction: column;
@@ -215,27 +231,18 @@
   font-size: fox.font-size(body-sm);
 }
 
-// ── 색상 스와치 ─────────────────────────────────────────────────────────────
-.swatchGrid {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
-  gap: fox.gap(4);
-}
-
-.swatch {
-  overflow: hidden;
-  border: fox.border(1) solid fox.color(border-neutral-subtle);
-  border-radius: fox.radius(3);
-  background: fox.color(surface-neutral-default);
-}
-
-.swatchChip {
-  block-size: fox.icon(7);
-  border-block-end: fox.border(1) solid fox.color(border-neutral-overlay);
-}
-
-.swatchMeta {
-  padding: fox.padding(5);
+// ── 색상 칩 ─────────────────────────────────────────────────────────────────
+// 값 앞에 붙는 5px 정사각형. 토큰 스케일에 5px(0.5rem)이 없어 리터럴을 쓴다 —
+// 카탈로그 표시용 치수이지 디자인 값이 아니다.
+// 테두리는 box-shadow로 그린다. border를 쓰면 5px 중 2px을 테두리가 먹어
+// 색이 보이는 면적이 3px로 줄어든다.
+.colorChip {
+  display: inline-block;
+  inline-size: 0.5rem;
+  block-size: 0.5rem;
+  margin-inline-end: fox.gap(3);
+  box-shadow: 0 0 0 fox.border(1) #{fox.color(border-neutral-overlay)};
+  vertical-align: middle;
 }
 
 .tokenName {
@@ -246,12 +253,30 @@
   word-break: break-all;
 }
 
-.tokenValue {
+// Figma 변수 경로 — 변수 페이지와 눈으로 맞추기 위한 보조 표기.
+.tokenFigma {
   display: block;
   margin-block-start: fox.gap(1);
   color: fox.color(font-neutral-subtler);
   font-size: fox.font-size(body-xsm);
   word-break: break-all;
+}
+
+// 다른 토큰을 가리키는 값. 실제 값과 구분되어야 "이게 참조구나"가 한눈에 보인다.
+.aliasRef {
+  color: fox.color(font-theme-primary);
+}
+
+// 제공되지 않은 컬렉션을 가리키는 참조 — 감사에서 걸러내야 할 항목이다.
+.aliasForeign {
+  color: fox.color(font-system-warning);
+}
+
+// 서체 토큰 미리보기 문장.
+.demoType {
+  margin: 0;
+  color: fox.color(font-neutral-default);
+  white-space: nowrap;
 }
 
 // ── 값 테이블 ───────────────────────────────────────────────────────────────
@@ -286,35 +311,6 @@
 .tdDemo {
   inline-size: 1%;
   white-space: nowrap;
-}
-
-// ── 서체 미리보기 ───────────────────────────────────────────────────────────
-.typeList {
-  display: flex;
-  flex-direction: column;
-}
-
-.typeRow {
-  display: flex;
-  flex-wrap: wrap;
-  gap: fox.gap(2) fox.gap(7);
-  align-items: baseline;
-  padding-block: fox.padding(5);
-  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
-}
-
-.typeMeta {
-  display: flex;
-  flex: 0 0 22rem;
-  flex-direction: column;
-  gap: fox.gap(1);
-}
-
-.typeSample {
-  min-inline-size: 20rem;
-  flex: 1;
-  margin: 0;
-  color: fox.color(font-neutral-default);
 }
 
 // ── 패딩 · 간격 미리보기 ────────────────────────────────────────────────────
 
@fox/dev-test/read-tokens.ts (deleted)
--- @fox/dev-test/read-tokens.ts
@@ -1,74 +0,0 @@
-export interface TokenEntry {
-  /** 그룹을 뗀 토큰 이름 (`--fox-color-background-default` → `background-default`) */
-  name: string;
-  /**
-   * 현재 테마·뷰포트에서 실제로 해석된 값 (`#FFFFFF`, `1.6rem`).
-   *
-   * 선언 원문이 아니라 계산값을 쓴다 — 색상은 Lightning CSS가 `light-dark()`를
-   * `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로
-   * 원문이 읽기 어렵고, 크기는 alias가 `var(--fox-…)` 참조라 원문만으로는 값을 알 수
-   * 없다. 계산값은 지금 화면에 실제로 적용되는 값과 정확히 일치한다.
-   */
-  value: string;
-  /** 스타일에서 참조할 커스텀 프로퍼티 전체 이름 */
-  property: string;
-}
-
-export type TokenRegistry = Record<string, TokenEntry[]>;
-
-const PREFIX = "--fox-";
-
-/**
- * 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다.
- *
- * 테스트 페이지가 토큰 목록을 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에
- * 값을 추가하면 여기에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다.
- * CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다.
- *
- * 브라우저에서만 동작한다(문서가 있어야 한다).
- */
-export function readTokenRegistry(): TokenRegistry {
-  const registry: TokenRegistry = {};
-  const seen = new Set<string>();
-  // 이름·순서는 CSSOM 규칙에서, 값은 계산 스타일에서 가져온다.
-  const computed = getComputedStyle(document.documentElement);
-
-  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) || rule.selectorText !== ":root") {
-        continue;
-      }
-
-      for (const property of Array.from(rule.style)) {
-        if (!property.startsWith(PREFIX) || seen.has(property)) {
-          continue;
-        }
-        seen.add(property);
-
-        const rest = property.slice(PREFIX.length);
-        const separator = rest.indexOf("-");
-        if (separator <= 0) {
-          continue;
-        }
-
-        (registry[rest.slice(0, separator)] ??= []).push({
-          name: rest.slice(separator + 1),
-          value:
-            computed.getPropertyValue(property).trim() ||
-            rule.style.getPropertyValue(property).trim(),
-          property,
-        });
-      }
-    }
-  }
-
-  return registry;
-}
@fox/dev-test/theme-switch.tsx
--- @fox/dev-test/theme-switch.tsx
+++ @fox/dev-test/theme-switch.tsx
@@ -1,9 +1,11 @@
 "use client";
 
 import { useSyncExternalStore } from "react";
-import { THEME_ATTRIBUTE } from "./use-token-registry";
 import styles from "./dev-test.module.scss";
 
+/** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */
+const THEME_ATTRIBUTE = "data-theme";
+
 type ThemeChoice = "system" | "light" | "dark";
 
 const CHOICES: { value: ThemeChoice; label: string }[] = [
 
@fox/dev-test/token-manifest.ts (added)
+++ @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
+++ @fox/dev-test/token-view.tsx
@@ -1,251 +1,181 @@
 "use client";
 
 import type { CSSProperties, ReactNode } from "react";
-import type { TokenEntry } from "./read-tokens";
+import type { ManifestSection, ManifestToken } from "./token-manifest";
 import styles from "./dev-test.module.scss";
 
 /**
- * 토큰 **목록**은 CSSOM에서 읽으므로 여기에 적지 않는다. 이 표는 각 그룹을 어떻게
- * 보여줄지와 한글 이름만 정한다. 목록에 없는 그룹이 새로 생겨도 값 표로 그려진다.
+ * 미리보기 — 토큰 이름으로 무엇을 보여줄지 고른다.
  *
- * - `swatch` 색을 칠한 카드
- * - `type`   그 서체 속성을 실제로 적용한 문장
- * - `demo`   그 토큰을 실제로 적용한 요소 (그룹별 렌더는 `DEMOS`)
- * - `value`  값만
+ * ⚠️ 값은 CSS 변수가 아니라 매니페스트의 **해석값**을 직접 넣는다. 변수를 쓰면 PC 섹션을
+ * 좁은 창에서 볼 때 모바일 값이 그려지고, Light 섹션이 다크 테마에서 다크 색으로 그려진다 —
+ * 이 화면은 "그 파일이 정의한 값"을 보여야 하므로 현재 테마·뷰포트에 흔들리면 안 된다.
+ * 인라인 `style`을 쓰는 예외인 이유도 같다: 렌더할 토큰을 미리 알 수 없다.
  */
-export const TOKEN_GROUPS: {
-  key: string;
-  label: string;
-  kind: "swatch" | "type" | "demo" | "value";
-  note?: string;
-}[] = [
+const PREVIEWS: { match: RegExp; render: (value: string) => ReactNode }[] = [
   {
-    key: "color",
-    label: "시맨틱 색상",
-    kind: "swatch",
-    note: "light/dark 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 좌측 전환기로 두 테마를 대조해 보세요.",
-  },
-  { key: "theme", label: "브랜드 램프", kind: "swatch", note: "primary · secondary · accent" },
-  {
-    key: "primitive",
-    label: "원시 팔레트",
-    kind: "swatch",
-    note: "화면에서 직접 쓰지 않는 바닥층입니다. 시맨틱 색상이 이 값들을 참조합니다.",
+    match: /(^|-)padding(-|$)/,
+    render: (value) => (
+      <div className={styles.demoPadBox} style={{ padding: value }}>
+        <p className={styles.demoText}>첫 번째 문단</p>
+        <p className={styles.demoText}>두 번째 문단</p>
+      </div>
+    ),
   },
   {
-    key: "font",
-    label: "서체",
-    kind: "type",
-    note: "각 토큰을 실제로 적용한 문장입니다. size 계열은 768px 경계에서 값이 바뀝니다.",
+    match: /(^|-)(gap|gutter)(-|$)|^spacing-/,
+    render: (value) => (
+      <div className={styles.demoGapBox} style={{ gap: value }}>
+        <p className={styles.demoText}>첫 번째 문단</p>
+        <p className={styles.demoText}>두 번째 문단</p>
+      </div>
+    ),
   },
   {
-    key: "padding",
-    label: "패딩",
-    kind: "demo",
-    note: "테두리와 문단 사이의 여백이 토큰 값입니다.",
-  },
-  { key: "gap", label: "간격", kind: "demo", note: "두 문단 사이의 거리가 토큰 값입니다." },
-  { key: "radius", label: "모서리 반경", kind: "demo" },
-  {
-    key: "icon",
-    label: "아이콘 크기",
-    kind: "demo",
-    note: "브라우저가 그리는 기본 체크박스를 토큰 크기로 키운 것입니다.",
-  },
-  { key: "border", label: "테두리 두께", kind: "demo", note: "border-0은 값이 0이라 보이지 않습니다." },
-  { key: "number", label: "원시 수치", kind: "value" },
-  {
-    key: "shadow",
-    label: "그림자 수치",
-    kind: "demo",
-    note: "Figma에는 offset·blur 수치만 있고 완성된 그림자 토큰이 없습니다. 각 수치의 효과를 보이려고 location은 `0 값 0`, blur는 `0 0 값`으로 조합했고 색은 alpha-default-inverse25를 씁니다 — 조합 자체는 토큰이 아닙니다.",
+    match: /(^|-)radius(-|$)/,
+    render: (value) => <div className={styles.demoRadius} style={{ borderRadius: value }} />,
   },
   {
-    key: "backdrop",
-    label: "배경 흐림",
-    kind: "demo",
-    note: "오른쪽 절반에만 backdrop-filter를 걸어 원본과 대조합니다.",
+    // 브라우저가 자체적으로 그리는 컨트롤이라 별도 에셋 없이 크기만 확인할 수 있다.
+    match: /(^|-)icon(-|$)/,
+    render: (value) => (
+      <input
+        type="checkbox"
+        defaultChecked
+        aria-label="크기 미리보기"
+        className={styles.demoIcon}
+        style={{ inlineSize: value, blockSize: value }}
+      />
+    ),
   },
-  { key: "form", label: "폼", kind: "value" },
-  { key: "card", label: "카드", kind: "value" },
-  { key: "modal", label: "모달", kind: "value" },
-  { key: "grid", label: "그리드", kind: "value" },
-  { key: "spacing", label: "여백", kind: "value" },
-  { key: "section", label: "섹션", kind: "value" },
+  {
+    match: /^border-/,
+    render: (value) => <div className={styles.demoBorder} style={{ borderWidth: value }} />,
+  },
+  {
+    match: /^shadow-/,
+    render: (value) => (
+      <div
+        className={styles.demoShadow}
+        style={{ boxShadow: `0 0 ${value} var(--demo-shadow-color)` }}
+      />
+    ),
+  },
+  {
+    match: /^backdrop-/,
+    render: (value) => (
+      <div className={styles.demoBackdrop}>
+        {/* eslint-disable-next-line @next/next/no-img-element -- @fox는 프레임워크에 의존하지
+            않아야 이식된다. next/image를 쓰면 이 폴더가 Next 전용이 되고 외부 호스트마다
+            remotePatterns 설정도 필요해진다. */}
+        <img
+          src="https://picsum.photos/seed/picsum/200/300"
+          alt=""
+          className={styles.demoBackdropImage}
+        />
+        <div
+          className={styles.demoBackdropOverlay}
+          style={{ backdropFilter: `blur(${value})`, WebkitBackdropFilter: `blur(${value})` }}
+        />
+      </div>
+    ),
+  },
+  {
+    match: /^font-size-/,
+    render: (value) => (
+      <p className={styles.demoType} style={{ fontSize: value }}>
+        다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123
+      </p>
+    ),
+  },
+  {
+    match: /^font-family-/,
+    render: (value) => (
+      <p className={styles.demoType} style={{ fontFamily: value }}>
+        다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123
+      </p>
+    ),
+  },
+  {
+    match: /^font-weight-/,
+    render: (value) => (
+      <p className={styles.demoType} style={{ fontWeight: value }}>
+        다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123
+      </p>
+    ),
+  },
 ];
 
-/**
- * ⚠️ 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 개수를 미리 알 수 없는 토큰을
- * 런타임에 순회해야 하는데, CSS 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서
- * 넣는 값은 디자인 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히
- * 토큰이 소유한다. 일반 컴포넌트에서는 이 패턴을 쓰지 않는다.
- */
-function v(token: TokenEntry): string {
-  return `var(${token.property})`;
-}
-
-/**
- * 화면에 표시할 토큰 식별자. `--fox-` 접두사만 뗀 **실제 이름**이라 CSS 변수
- * (`--fox-color-background-default`)와 그대로 대응한다 — 함수 표기(`fox.color(...)`)는
- * 그룹명이 괄호 밖으로 빠져 목록을 훑을 때 이름이 한눈에 안 들어온다.
- */
-function label(token: TokenEntry): string {
-  return token.property.replace("--fox-", "");
-}
-
-// ── 그룹별 미리보기 ─────────────────────────────────────────────────────────
-const DEMOS: Record<string, (token: TokenEntry) => ReactNode> = {
-  padding: (token) => (
-    <div className={styles.demoPadBox} style={{ padding: v(token) }}>
-      <p className={styles.demoText}>첫 번째 문단</p>
-      <p className={styles.demoText}>두 번째 문단</p>
-    </div>
-  ),
-
-  gap: (token) => (
-    <div className={styles.demoGapBox} style={{ gap: v(token) }}>
-      <p className={styles.demoText}>첫 번째 문단</p>
-      <p className={styles.demoText}>두 번째 문단</p>
-    </div>
-  ),
-
-  radius: (token) => <div className={styles.demoRadius} style={{ borderRadius: v(token) }} />,
-
-  // 브라우저가 자체적으로 그리는 컨트롤이라 별도 에셋 없이 크기만 확인할 수 있다.
-  icon: (token) => (
-    <input
-      type="checkbox"
-      defaultChecked
-      aria-label={`icon-${token.name} 크기 미리보기`}
-      className={styles.demoIcon}
-      style={{ inlineSize: v(token), blockSize: v(token) }}
-    />
-  ),
-
-  border: (token) => <div className={styles.demoBorder} style={{ borderWidth: v(token) }} />,
-
-  shadow: (token) => (
-    <div
-      className={styles.demoShadow}
-      style={{
-        boxShadow: token.name.startsWith("blur")
-          ? `0 0 ${v(token)} var(--demo-shadow-color)`
-          : `0 ${v(token)} 0 var(--demo-shadow-color)`,
-      }}
-    />
-  ),
-
-  backdrop: (token) => (
-    <div className={styles.demoBackdrop}>
-      {/* eslint-disable-next-line @next/next/no-img-element -- @fox는 프레임워크에 의존하지
-          않아야 이식된다. next/image를 쓰면 이 폴더가 Next 전용이 되고 외부 호스트마다
-          remotePatterns 설정도 필요해진다. */}
-      <img
-        src="https://picsum.photos/seed/picsum/200/300"
-        alt=""
-        className={styles.demoBackdropImage}
-      />
-      <div
-        className={styles.demoBackdropOverlay}
-        style={{
-          backdropFilter: `blur(${v(token)})`,
-          WebkitBackdropFilter: `blur(${v(token)})`,
-        }}
-      />
-    </div>
-  ),
-};
-
-// ── 렌더러 ──────────────────────────────────────────────────────────────────
-function Swatches({ tokens }: { tokens: TokenEntry[] }) {
-  return (
-    <div className={styles.swatchGrid}>
-      {tokens.map((token) => (
-        <div key={token.property} className={styles.swatch}>
-          <div className={styles.swatchChip} style={{ background: v(token) }} />
-          <div className={styles.swatchMeta}>
-            <span className={styles.tokenName}>{label(token)}</span>
-            <span className={styles.tokenValue}>{token.value}</span>
-          </div>
-        </div>
-      ))}
-    </div>
-  );
-}
-
-/** 서체 토큰은 이름 앞머리가 곧 적용할 CSS 속성이다. */
-function typeStyle(token: TokenEntry): CSSProperties {
-  if (token.name.startsWith("family-")) {
-    return { fontFamily: v(token) };
+function previewFor(token: ManifestToken): ReactNode {
+  if (token.type === "color") {
+    // 색은 값 칸의 칩이 이미 보여준다.
+    return null;
   }
-  if (token.name.startsWith("weight-")) {
-    return { fontWeight: v(token) };
-  }
-  return { fontSize: v(token) };
+  const hit = PREVIEWS.find((entry) => entry.match.test(token.name));
+  return hit ? hit.render(token.resolved) : null;
 }
 
-function TypeList({ tokens }: { tokens: TokenEntry[] }) {
-  return (
-    <div className={styles.typeList}>
-      {tokens.map((token) => (
-        <div key={token.property} className={styles.typeRow}>
-          <div className={styles.typeMeta}>
-            <span className={styles.tokenName}>{label(token)}</span>
-            <span className={styles.tokenValue}>{token.value}</span>
-          </div>
-          <p className={styles.typeSample} style={typeStyle(token)}>
-            다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123
-          </p>
-        </div>
-      ))}
-    </div>
-  );
+/** 참조 대상이 우리가 갖고 있지 않은 컬렉션일 때 생성기가 붙이는 표시. */
+function isForeign(alias: string | null): boolean {
+  return Boolean(alias?.includes("미제공"));
 }
 
-function TokenTable({
-  tokens,
-  demo,
-}: {
-  tokens: TokenEntry[];
-  demo?: (token: TokenEntry) => ReactNode;
-}) {
-  return (
-    <table className={styles.table}>
-      <thead>
-        <tr>
-          <th scope="col">토큰</th>
-          <th scope="col">값</th>
-          {demo ? <th scope="col">미리보기</th> : null}
-        </tr>
-      </thead>
-      <tbody>
-        {tokens.map((token) => (
-          <tr key={token.property}>
-            <td>{label(token)}</td>
-            <td className={styles.tdValue}>{token.value}</td>
-            {demo ? <td className={styles.tdDemo}>{demo(token)}</td> : null}
-          </tr>
-        ))}
-      </tbody>
-    </table>
-  );
+function chipStyle(token: ManifestToken): CSSProperties {
+  return { background: token.resolved };
 }
 
-export function TokenView({ group, tokens }: { group: string; tokens: TokenEntry[] }) {
-  const meta = TOKEN_GROUPS.find((entry) => entry.key === group);
-  const kind = meta?.kind ?? "value";
+export function TokenView({ section }: { section: ManifestSection }) {
+  const hasPreview = section.tokens.some((token) => previewFor(token) !== null);
+  const foreignCount = section.tokens.filter((token) => isForeign(token.alias)).length;
 
   return (
     <section className={styles.section}>
       <h2 className={styles.sectionTitle}>
-        {meta?.label ?? group} · {tokens.length}
+        {section.label} · {section.tokens.length}
       </h2>
-      {meta?.note ? <p className={styles.sectionNote}>{meta.note}</p> : null}
+      <p className={styles.sectionNote}>
+        {section.file} — 파일에 값이 적혀 있으면 그 값을, 다른 토큰을 가리키면 참조 토큰을
+        표시합니다.
+        {foreignCount > 0
+          ? ` 이 중 ${foreignCount}개는 제공되지 않은 컬렉션을 가리킵니다.`
+          : ""}
+      </p>
 
-      {kind === "swatch" ? <Swatches tokens={tokens} /> : null}
-      {kind === "type" ? <TypeList tokens={tokens} /> : null}
-      {kind === "demo" ? <TokenTable tokens={tokens} demo={DEMOS[group]} /> : null}
-      {kind === "value" ? <TokenTable tokens={tokens} /> : null}
+      <table className={styles.table}>
+        <thead>
+          <tr>
+            <th scope="col">토큰</th>
+            <th scope="col">값 / 참조</th>
+            {hasPreview ? <th scope="col">미리보기</th> : null}
+          </tr>
+        </thead>
+        <tbody>
+          {section.tokens.map((token) => (
+            <tr key={token.name}>
+              <td>
+                <span className={styles.tokenName}>{token.name}</span>
+                <span className={styles.tokenFigma}>{token.figma}</span>
+              </td>
+              <td className={styles.tdValue}>
+                {token.type === "color" ? (
+                  <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}
+                  </span>
+                )}
+              </td>
+              {hasPreview ? <td className={styles.tdDemo}>{previewFor(token)}</td> : null}
+            </tr>
+          ))}
+        </tbody>
+      </table>
     </section>
   );
 }
 
@fox/dev-test/use-token-registry.ts (deleted)
--- @fox/dev-test/use-token-registry.ts
@@ -1,58 +0,0 @@
-import { useSyncExternalStore } from "react";
-import { readTokenRegistry, type TokenRegistry } from "./read-tokens";
-
-// CSSOM은 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다
-// (effect에서 setState 하면 렌더가 연쇄된다).
-//
-// 토큰 **값**은 테마와 뷰포트에 따라 달라지므로 둘 중 하나가 바뀌면 다시 읽어야 한다.
-// 반면 `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 매번 새 객체를
-// 돌려주면 무한 루프가 된다 — 그래서 (테마, 뷰포트 구간)을 키로 캐시해 같은 조건
-// 동안에는 동일한 참조를 반환한다.
-let cached: { key: string; registry: TokenRegistry } | null = null;
-
-/** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */
-export const THEME_ATTRIBUTE = "data-theme";
-
-function snapshotKey(): string {
-  const theme = document.documentElement.getAttribute(THEME_ATTRIBUTE) ?? "system";
-  // 폭 자체가 아니라 "반응형 토큰이 바뀌는 구간"만 키에 넣는다 — 창을 1px 줄일 때마다
-  // 전체 토큰을 다시 읽으면 낭비다.
-  const wide = window.matchMedia("(min-width: 768px)").matches ? "pc" : "mobile";
-  return `${theme}:${wide}`;
-}
-
-function subscribe(onChange: () => void): () => void {
-  const observer = new MutationObserver(onChange);
-  observer.observe(document.documentElement, {
-    attributes: true,
-    attributeFilter: [THEME_ATTRIBUTE],
-  });
-
-  const query = window.matchMedia("(min-width: 768px)");
-  query.addEventListener("change", onChange);
-
-  return () => {
-    observer.disconnect();
-    query.removeEventListener("change", onChange);
-  };
-}
-
-function getSnapshot(): TokenRegistry | null {
-  const key = snapshotKey();
-  if (!cached || cached.key !== key) {
-    cached = { key, registry: readTokenRegistry() };
-  }
-  return cached.registry;
-}
-
-function getServerSnapshot(): TokenRegistry | null {
-  return null;
-}
-
-/**
- * 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 토큰 레지스트리를 반환한다.
- * 테마를 바꾸거나 768px 경계를 넘으면 그 조건에서 해석된 값으로 갱신된다.
- */
-export function useTokenRegistry(): TokenRegistry | null {
-  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-}
@fox/tools/build-tokens.py
--- @fox/tools/build-tokens.py
+++ @fox/tools/build-tokens.py
@@ -48,6 +48,13 @@
     raw[key] = OrderedDict(leaves(doc))
 
 
+# 참조 판별용 원본 값 — resolve_refs가 덮어쓰기 전에 보관한다. 감사 화면이
+# "파일에 실제 값이 적혀 있었는가, 다른 토큰을 가리켰는가"를 구분하려면 원본이 필요하다.
+ORIGINAL_VALUE = {
+    (coll, path): node["$value"] for coll, table in raw.items() for path, node in table.items()
+}
+
+
 def resolve_refs(table):
     """같은 파일 안의 {a.b.c} 참조를 실제 값으로 치환한다."""
     for path, node in table.items():
@@ -473,6 +480,139 @@
 @use "root";
 """))
 
+# ── 감사용 매니페스트 ────────────────────────────────────────────────────────
+# Figma 변수 페이지와 1:1로 대조하기 위한 데이터다. SCSS/CSS는 alias를 var() 참조로
+# 흡수하고 light/dark를 light-dark() 한 줄로 합치기 때문에, 브라우저에서 읽으면
+# "원래 파일이 무엇을 적어 뒀는지"를 되살릴 수 없다. 그래서 원본 구조 그대로를 따로 낸다.
+NAMERS = {
+    "primitive": name_primitive,
+    "theme": name_theme,
+    "size": name_size,
+    "light": name_semantic,
+    "dark": name_semantic,
+    "pc": name_responsive,
+    "mobile": name_responsive,
+}
+
+SECTIONS = [
+    ("light", "Light", "Mode"),
+    ("dark", "Dark", "Mode"),
+    ("pc", "PC", "Responsive"),
+    ("mobile", "Mobile", "Responsive"),
+    ("size", "Size", None),
+    ("theme", "Theme", None),
+    ("primitive", "Primitive", None),
+]
+
+
+def literal_text(coll, path, node):
+    """파일에 적힌 값을 Figma 표기 그대로 낸다 — 변수 페이지와 눈으로 맞추기 위함이다."""
+    value = node["$value"]
+    if node["$type"] == "color":
+        return css_color(value)
+    if node["$type"] == "string":
+        return str(value)
+    return str(value)
+
+
+def resolved_text(coll, path, node):
+    """우리 단위로 변환된 최종 값. 길이는 rem, 색은 hex/rgb()."""
+    value = node["$value"]
+    if node["$type"] == "color":
+        return css_color(value)
+    if node["$type"] == "string":
+        return str(value)
+    if coll == "primitive" and path.startswith("font.weight"):
+        return str(value)
+    return css_len(value)
+
+
+def manifest_rows(coll):
+    namer = NAMERS[coll]
+    rows = []
+    # light/dark는 SCSS 출력과 같은 순서로 맞춘다 — 소속이 불분명한 chart-alt 29개를
+    # 뒤로 보내야 주요 색상이 먼저 보인다(원본 JSON에서는 이들이 루트에 먼저 온다).
+    entries = raw[coll].items()
+    if coll in ("light", "dark"):
+        entries = sorted(entries, key=lambda kv: kv[0] in CHART_ALT_GROUP)
+    for path, node in entries:
+        original = ORIGINAL_VALUE[(coll, path)]
+        alias = None
+        literal = None
+
+        if isinstance(original, str) and original.startswith("{"):
+            # 같은 파일 안의 다른 토큰을 가리킨다.
+            alias = namer(original[1:-1])
+        else:
+            data = node.get("$extensions", {}).get("com.figma.aliasData")
+            if data:
+                hit = alias_lookup.get((data["targetVariableSetName"], data["targetVariableName"]))
+                # ⚠️ 이름만으로 매칭하면 안 된다 — 이 export에는 `theme`이라는 이름의
+                # 컬렉션이 둘 있고(제공된 파랑 계열, 미제공 청록 계열) 변수 경로까지
+                # 같다. 값이 일치할 때만 우리 토큰으로 인정하고, 아니면 우리가 갖고
+                # 있지 않은 컬렉션을 가리키는 것으로 표시한다.
+                if hit and same_value(hit[1], node["$value"]):
+                    alias = hit[0]
+                else:
+                    alias = f'{data["targetVariableSetName"]}/{data["targetVariableName"]} (미제공 컬렉션)'
+            else:
+                literal = literal_text(coll, path, node)
+
+        rows.append(
+            {
+                "name": namer(path),
+                "figma": path.replace(".", "/"),
+                "type": node["$type"],
+                "literal": literal,
+                "alias": alias,
+                # 그 파일/모드에서 최종 해석된 값(우리 단위 기준). 미리보기가 현재
+                # 테마·뷰포트가 아니라 **그 섹션이 정의한 값**을 그려야 하므로, CSS
+                # 변수가 아니라 이 값을 직접 쓴다.
+                "resolved": resolved_text(coll, path, node),
+            }
+        )
+    return rows
+
+
+manifest = [
+    {"id": coll, "label": label_, "category": category, "file": FILES[coll], "tokens": manifest_rows(coll)}
+    for coll, label_, category in SECTIONS
+]
+
+manifest_ts = """// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다.
+// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 `@fox/tools/build-tokens.py`를 재실행하세요.
+//
+// Figma 변수 페이지와 대조하기 위한 데이터입니다. CSS는 alias를 var() 참조로 흡수하고
+// light/dark를 light-dark() 한 줄로 합치므로, 브라우저에서 읽어서는 "원본 파일이 무엇을
+// 적어 뒀는지"를 되살릴 수 없습니다 — 그래서 원본 구조를 그대로 담습니다.
+
+export interface ManifestToken {
+  /** 우리 CSS 변수 이름에서 `--fox-`를 뗀 것 */
+  name: string;
+  /** 원본 JSON에서의 경로 (= Figma 변수 이름) */
+  figma: string;
+  type: "color" | "number" | "string";
+  /** 파일에 값이 직접 적혀 있을 때만 채워진다 */
+  literal: string | null;
+  /** 다른 토큰을 가리킬 때만 채워진다 */
+  alias: string | null;
+  /** 그 파일/모드에서 최종 해석된 값 — 미리보기 전용(현재 테마·뷰포트와 무관) */
+  resolved: string;
+}
+
+export interface ManifestSection {
+  id: string;
+  label: string;
+  /** 사이드바 상위 분류. 없으면 최상위 항목이다. */
+  category: string | null;
+  file: string;
+  tokens: ManifestToken[];
+}
+
+export const TOKEN_MANIFEST: ManifestSection[] = """ + json.dumps(manifest, ensure_ascii=False, indent=2) + ";\n"
+
+written.append(write("../dev-test/token-manifest.ts", manifest_ts))
+
 print("생성된 파일:")
 for w in written:
     print("  ", w)
Add a comment
List