임동욱 임동욱 08-11
feat: 디자인 시스템 테스트 하니스를 @fox/dev-test로 이관
토큰 배치 확인과 컴포넌트 예제를 한 페이지에서 본다. 사이드바는 토큰/컴포넌트
두 대분류이며, 토큰 그룹은 CSSOM에서 읽으므로 목록을 코드에 두지 않는다.

컴포넌트는 component-registry의 배열에 항목 하나만 추가하면 사이드바와 본문이
함께 갱신된다 — 만들 때마다 다른 파일을 고칠 필요가 없다.

하니스를 앱이 아니라 @fox에 두는 이유는 폴더를 다른 프로젝트로 복사할 때 함께
따라가게 하기 위함이다. 그래서 앱 상수(테마 저장 키 등)를 import 하지 않고,
테마 전환은 @fox가 소유한 data-theme 계약만 건드린다(저장하지 않는다).

선택 상태는 URL 해시가 SSOT라 새로고침해도 보던 섹션이 유지된다.

같은 목적의 카탈로그를 두 벌 두면 갈라지므로 구 app/design은 함께 제거한다.

Co-Authored-By: Claude Opus 5 
@9dc9ee30d32b190ba10dd2ff4a08765e0ed5530e
 
@fox/dev-test/component-registry.tsx (added)
+++ @fox/dev-test/component-registry.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from "react";
+
+/**
+ * 컴포넌트 예제 하나. 사이드바의 "컴포넌트" 분류에 항목으로 뜬다.
+ */
+export interface ComponentExample {
+  /** 해시 링크와 React key에 쓰이는 식별자. kebab-case. */
+  id: string;
+  /** 사이드바에 표시할 이름. */
+  name: string;
+  /** 한 줄 설명 (선택). */
+  description?: string;
+  /**
+   * 이 컴포넌트가 가질 수 있는 상태들. variant·size·disabled처럼 **눈으로 비교해야 하는
+   * 조합을 빠짐없이** 넣는다 — 예제가 곧 회귀 확인 수단이다.
+   */
+  variants: { label: string; node: ReactNode }[];
+}
+
+/**
+ * 컴포넌트를 만들 때마다 여기에 한 항목씩 추가한다. 사이드바·본문은 이 배열만 보고
+ * 그리므로 다른 파일을 고칠 필요가 없다.
+ *
+ * ```tsx
+ * import { Button } from "../core/components/button";
+ *
+ * export const COMPONENT_EXAMPLES: ComponentExample[] = [
+ *   {
+ *     id: "button",
+ *     name: "Button",
+ *     description: "기본 액션 버튼",
+ *     variants: [
+ *       { label: "primary", node: <Button variant="primary">확인</Button> },
+ *       { label: "disabled", node: <Button disabled>확인</Button> },
+ *     ],
+ *   },
+ * ];
+ * ```
+ */
+export const COMPONENT_EXAMPLES: ComponentExample[] = [];
 
@fox/dev-test/component-view.tsx (added)
+++ @fox/dev-test/component-view.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import type { ComponentExample } from "./component-registry";
+import styles from "./dev-test.module.scss";
+
+export function ComponentView({ example }: { example: ComponentExample }) {
+  return (
+    <section className={styles.section}>
+      <h2 className={styles.sectionTitle}>{example.name}</h2>
+      {example.description ? (
+        <p className={styles.sectionNote}>{example.description}</p>
+      ) : null}
+
+      <div className={styles.variantList}>
+        {example.variants.map((variant) => (
+          <div key={variant.label} className={styles.variant}>
+            <span className={styles.variantLabel}>{variant.label}</span>
+            <div className={styles.variantStage}>{variant.node}</div>
+          </div>
+        ))}
+      </div>
+    </section>
+  );
+}
+
+export function ComponentEmpty() {
+  return (
+    <section className={styles.section}>
+      <h2 className={styles.sectionTitle}>컴포넌트가 아직 없습니다</h2>
+      <p className={styles.sectionNote}>
+        컴포넌트를 만들면 <code>@fox/dev-test/component-registry.tsx</code>의{" "}
+        <code>COMPONENT_EXAMPLES</code> 배열에 항목을 하나 추가하세요. 사이드바와 본문은 그
+        배열만 보고 그리므로 다른 파일은 고칠 필요가 없습니다.
+      </p>
+      <pre className={styles.code}>{`{
+  id: "button",
+  name: "Button",
+  description: "기본 액션 버튼",
+  variants: [
+    { label: "primary", node: <Button variant="primary">확인</Button> },
+    { label: "disabled", node: <Button disabled>확인</Button> },
+  ],
+}`}</pre>
+    </section>
+  );
+}
 
@fox/dev-test/dev-test-page.tsx (added)
+++ @fox/dev-test/dev-test-page.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+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 styles from "./dev-test.module.scss";
+
+// 선택 상태는 URL 해시가 SSOT다 — 새로고침하거나 링크를 공유해도 보던 섹션이 유지되고,
+// React state를 따로 두지 않으므로 둘이 어긋날 일이 없다.
+// 형식: `#token:color` / `#component:button`
+function subscribeHash(onChange: () => void): () => void {
+  window.addEventListener("hashchange", onChange);
+  return () => window.removeEventListener("hashchange", onChange);
+}
+
+function getHash(): string {
+  return window.location.hash.slice(1);
+}
+
+function getServerHash(): string {
+  return "";
+}
+
+function useSelection(): { kind: string; id: string } | null {
+  const hash = useSyncExternalStore(subscribeHash, getHash, getServerHash);
+  const [kind, id] = hash.split(":");
+  return kind && id ? { kind, id } : null;
+}
+
+function select(kind: string, id: string): void {
+  window.location.hash = `${kind}:${id}`;
+}
+
+export function DevTestPage() {
+  const registry = useTokenRegistry();
+  const selection = useSelection();
+
+  if (!registry) {
+    return (
+      <div className={styles.shell}>
+        <p className={styles.loading}>토큰을 읽는 중…</p>
+      </div>
+    );
+  }
+
+  // 사이드바에 뜰 토큰 그룹 — 정의된 순서를 우선하고, 목록에 없는 그룹은 뒤에 붙인다.
+  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];
+
+  const current = selection ?? { kind: "token", id: tokenGroups[0]?.key ?? "" };
+  const totalTokens = Object.values(registry).reduce((sum, list) => sum + list.length, 0);
+
+  return (
+    <div className={styles.shell}>
+      <aside className={styles.sidebar}>
+        <div className={styles.brand}>
+          <span className={styles.brandTitle}>@fox 디자인 시스템</span>
+          <span className={styles.brandNote}>개발 전용 테스트 페이지</span>
+        </div>
+
+        <ThemeSwitch />
+
+        <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>
+          </div>
+
+          <div className={styles.navGroup}>
+            <h2 className={styles.navGroupTitle}>컴포넌트 · {COMPONENT_EXAMPLES.length}</h2>
+            {COMPONENT_EXAMPLES.length ? (
+              <ul className={styles.navList}>
+                {COMPONENT_EXAMPLES.map((example) => (
+                  <li key={example.id}>
+                    <button
+                      type="button"
+                      className={styles.navItem}
+                      aria-current={
+                        current.kind === "component" && current.id === example.id
+                          ? "page"
+                          : undefined
+                      }
+                      onClick={() => select("component", example.id)}
+                    >
+                      <span>{example.name}</span>
+                      <span className={styles.navCount}>{example.variants.length}</span>
+                    </button>
+                  </li>
+                ))}
+              </ul>
+            ) : (
+              <button
+                type="button"
+                className={styles.navItem}
+                aria-current={current.kind === "component" ? "page" : undefined}
+                onClick={() => select("component", "none")}
+              >
+                <span>추가 방법</span>
+              </button>
+            )}
+          </div>
+        </nav>
+      </aside>
+
+      <main className={styles.content}>
+        {current.kind === "component" ? (
+          (() => {
+            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]} />
+        ) : (
+          <p className={styles.loading}>선택한 토큰 그룹이 없습니다.</p>
+        )}
+      </main>
+    </div>
+  );
+}
 
@fox/dev-test/dev-test.module.scss (added)
+++ @fox/dev-test/dev-test.module.scss
@@ -0,0 +1,294 @@
+// 개발 전용 테스트 페이지 스타일.
+//
+// 이 파일 자체가 토큰 규율의 첫 소비처다 — 여기 있는 모든 값은 `fox.*()` 함수를 거치므로,
+// 없는 토큰을 쓰면 빌드가 실패한다. 페이지가 그려진다는 것은 사용된 토큰이 전부 실재한다는
+// 뜻이기도 하다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.shell {
+  display: grid;
+  grid-template-columns: 1fr;
+  min-block-size: 100vh;
+  background: fox.color(background-default);
+  color: fox.color(font-neutral-default);
+
+  @include fox.pc {
+    grid-template-columns: 26rem 1fr;
+  }
+}
+
+.loading {
+  padding: fox.padding(8);
+  color: fox.color(font-neutral-subtle);
+  font-size: fox.font-size(body-md);
+}
+
+// ── 사이드바 ────────────────────────────────────────────────────────────────
+.sidebar {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(5);
+  padding: fox.padding(7);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
+  background: fox.color(surface-neutral-gray);
+
+  @include fox.pc {
+    position: sticky;
+    inset-block-start: 0;
+    block-size: 100vh;
+    overflow-y: auto;
+    border-block-end: 0;
+    border-inline-end: fox.border(1) solid fox.color(border-neutral-subtle);
+  }
+}
+
+.brand {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(1);
+}
+
+.brandTitle {
+  font-size: fox.font-size(heading-sm);
+  font-weight: fox.font-weight(bold);
+}
+
+.brandNote {
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-xsm);
+}
+
+.themeSwitch {
+  display: flex;
+  gap: fox.gap(1);
+  padding: fox.padding(1);
+  border: fox.border(1) solid fox.color(border-neutral-default);
+  border-radius: fox.radius(max);
+  background: fox.color(surface-neutral-default);
+}
+
+.themeButton {
+  flex: 1;
+  padding: fox.padding(3) fox.padding(4);
+  border-radius: fox.radius(max);
+  color: fox.color(font-neutral-subtle);
+  font-size: fox.font-size(label-xsm);
+  font-weight: fox.font-weight(medium);
+
+  &[aria-pressed="true"] {
+    background: fox.color(button-primary-surface);
+    color: fox.color(button-primary-font);
+  }
+
+  &:focus-visible {
+    outline: fox.border(2) solid fox.color(border-theme-primary);
+    outline-offset: fox.border(2);
+  }
+}
+
+.nav {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(6);
+}
+
+.navGroup {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.navGroupTitle {
+  padding-inline: fox.padding(3);
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-xsm);
+  font-weight: fox.font-weight(bold);
+  letter-spacing: 0.04em;
+}
+
+.navList {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(1);
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.navItem {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  justify-content: space-between;
+  gap: fox.gap(3);
+  padding: fox.padding(3) fox.padding(4);
+  border-radius: fox.radius(3);
+  color: fox.color(font-neutral-subtle);
+  font-size: fox.font-size(label-sm);
+  text-align: start;
+
+  &:hover {
+    background: fox.color(button-default-surface-hover);
+    color: fox.color(font-neutral-default);
+  }
+
+  &[aria-current="page"] {
+    background: fox.color(button-primary-surface);
+    color: fox.color(button-primary-font);
+  }
+
+  &:focus-visible {
+    outline: fox.border(2) solid fox.color(border-theme-primary);
+    outline-offset: fox.border(1);
+  }
+}
+
+.navCount {
+  color: currentcolor;
+  font-size: fox.font-size(label-xsm);
+  opacity: 0.7;
+}
+
+// ── 본문 ────────────────────────────────────────────────────────────────────
+.content {
+  min-inline-size: 0;
+  padding: fox.spacing(top-md) fox.padding(7) fox.spacing(bottom-xlg);
+
+  @include fox.pc {
+    padding: fox.spacing(top-lg) fox.padding(11) fox.spacing(bottom-xlg);
+  }
+}
+
+.section {
+  display: flex;
+  flex-direction: column;
+}
+
+.sectionTitle {
+  font-size: fox.font-size(heading-md);
+  font-weight: fox.font-weight(bold);
+}
+
+.sectionNote {
+  margin-block: fox.gap(2) fox.gap(6);
+  color: fox.color(font-neutral-subtle);
+  font-size: fox.font-size(body-sm);
+}
+
+.code {
+  overflow-x: auto;
+  margin-block-start: fox.gap(4);
+  padding: fox.padding(6);
+  border: fox.border(1) solid fox.color(border-neutral-subtle);
+  border-radius: fox.radius(3);
+  background: fox.color(surface-neutral-gray);
+  color: fox.color(font-neutral-default);
+  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);
+}
+
+.tokenName {
+  display: block;
+  color: fox.color(font-neutral-default);
+  font-size: fox.font-size(label-sm);
+  font-weight: fox.font-weight(medium);
+  word-break: break-all;
+}
+
+.tokenValue {
+  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;
+}
+
+// ── 값 테이블 ───────────────────────────────────────────────────────────────
+.table {
+  inline-size: 100%;
+  border-collapse: collapse;
+  text-align: start;
+}
+
+.table th,
+.table td {
+  padding: fox.padding(3) fox.padding(5);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
+}
+
+.table th {
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-xsm);
+  font-weight: fox.font-weight(medium);
+}
+
+.table td {
+  color: fox.color(font-neutral-default);
+  font-size: fox.font-size(body-sm);
+}
+
+.tdValue {
+  color: fox.color(font-neutral-subtle);
+}
+
+// 크기 토큰을 눈으로 비교하기 위한 막대. 폭은 인라인 style이 토큰을 참조해 넣는다.
+.bar {
+  display: block;
+  block-size: fox.icon(2);
+  border-radius: fox.radius(1);
+  background: fox.color(surface-theme-primary);
+}
+
+// ── 컴포넌트 예제 ───────────────────────────────────────────────────────────
+.variantList {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(5);
+}
+
+.variant {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.variantLabel {
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-xsm);
+  font-weight: fox.font-weight(medium);
+}
+
+// 예제를 얹는 무대. 컴포넌트 자체의 배경·여백과 섞이지 않도록 중립 표면 위에 올린다.
+.variantStage {
+  display: flex;
+  flex-wrap: wrap;
+  gap: fox.gap(4);
+  align-items: center;
+  padding: fox.padding(7);
+  border: fox.border(1) solid fox.color(border-neutral-subtle);
+  border-radius: fox.radius(3);
+  background: fox.color(surface-neutral-default);
+}
 
@fox/dev-test/index.ts (added)
+++ @fox/dev-test/index.ts
@@ -0,0 +1,2 @@
+export { DevTestPage } from "./dev-test-page";
+export { COMPONENT_EXAMPLES, type ComponentExample } from "./component-registry";
@fox/dev-test/read-tokens.ts (Renamed from app/design/_lib/read-tokens.ts)
--- app/design/_lib/read-tokens.ts
+++ @fox/dev-test/read-tokens.ts
@@ -6,7 +6,7 @@
    *
    * 선언 원문이 아니라 계산값을 쓴다 — 색상은 Lightning CSS가 `light-dark()`를
    * `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로
-   * 원문이 읽기 어렵고, 크기는 `alias`가 `var(--fox-…)` 참조라 원문만으로는 값을 알 수
+   * 원문이 읽기 어렵고, 크기는 alias가 `var(--fox-…)` 참조라 원문만으로는 값을 알 수
    * 없다. 계산값은 지금 화면에 실제로 적용되는 값과 정확히 일치한다.
    */
   value: string;
@@ -21,8 +21,8 @@
 /**
  * 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다.
  *
- * 토큰 목록을 카탈로그가 따로 들고 있지 않게 하려는 것이 목적이다 — `@fox/styles/tokens/`에
- * 값을 추가하면 카탈로그에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다.
+ * 테스트 페이지가 토큰 목록을 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에
+ * 값을 추가하면 여기에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다.
  * CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다.
  *
  * 브라우저에서만 동작한다(문서가 있어야 한다).
 
@fox/dev-test/theme-switch.tsx (added)
+++ @fox/dev-test/theme-switch.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { THEME_ATTRIBUTE } from "./use-token-registry";
+import styles from "./dev-test.module.scss";
+
+type ThemeChoice = "system" | "light" | "dark";
+
+const CHOICES: { value: ThemeChoice; label: string }[] = [
+  { value: "system", label: "시스템" },
+  { value: "light", label: "라이트" },
+  { value: "dark", label: "다크" },
+];
+
+/**
+ * `<html data-theme>` 자체가 SSOT이므로 React state를 두지 않고 DOM을 구독한다 —
+ * 서버/클라이언트 출력이 갈리지 않아 hydration 불일치 표면적이 0이다.
+ *
+ * 개발용 미리보기 컨트롤이라 **선택을 저장하지 않는다.** 저장 키는 호스트 앱이 소유하는
+ * 값이라(앱마다 다르다) `@fox`가 알 필요가 없고, 알면 포터빌리티가 깨진다.
+ */
+function subscribe(onChange: () => void): () => void {
+  const observer = new MutationObserver(onChange);
+  observer.observe(document.documentElement, {
+    attributes: true,
+    attributeFilter: [THEME_ATTRIBUTE],
+  });
+  return () => observer.disconnect();
+}
+
+function getSnapshot(): ThemeChoice {
+  const value = document.documentElement.getAttribute(THEME_ATTRIBUTE);
+  return value === "light" || value === "dark" ? value : "system";
+}
+
+function getServerSnapshot(): ThemeChoice {
+  return "system";
+}
+
+function apply(next: ThemeChoice): void {
+  const root = document.documentElement;
+  if (next === "system") {
+    root.removeAttribute(THEME_ATTRIBUTE);
+  } else {
+    root.setAttribute(THEME_ATTRIBUTE, next);
+  }
+}
+
+export function ThemeSwitch() {
+  const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+
+  return (
+    <div className={styles.themeSwitch} role="group" aria-label="테마 전환">
+      {CHOICES.map(({ value, label }) => (
+        <button
+          key={value}
+          type="button"
+          className={styles.themeButton}
+          aria-pressed={current === value}
+          onClick={() => apply(value)}
+        >
+          {label}
+        </button>
+      ))}
+    </div>
+  );
+}
 
@fox/dev-test/token-view.tsx (added)
+++ @fox/dev-test/token-view.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import type { CSSProperties } from "react";
+import type { TokenEntry } from "./read-tokens";
+import styles from "./dev-test.module.scss";
+
+/**
+ * 토큰 **목록**은 CSSOM에서 읽으므로 여기에 적지 않는다. 이 표는 각 그룹을 어떻게
+ * 보여줄지(색은 스와치, 길이는 막대, 나머지는 값)와 한글 이름만 정한다.
+ * 목록에 없는 그룹이 새로 생겨도 값 표로 그려진다 — 빠지지 않는다.
+ */
+export const TOKEN_GROUPS: {
+  key: string;
+  label: string;
+  kind: "swatch" | "bar" | "value";
+  note?: string;
+}[] = [
+  {
+    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: "화면에서 직접 쓰지 않는 바닥층입니다. 시맨틱 색상이 이 값들을 참조합니다.",
+  },
+  {
+    key: "font",
+    label: "서체",
+    kind: "value",
+    note: "size 계열은 768px 경계에서 값이 바뀝니다.",
+  },
+  { key: "padding", label: "패딩", kind: "bar" },
+  { key: "gap", label: "간격", kind: "bar" },
+  { key: "radius", label: "모서리 반경", kind: "bar" },
+  { key: "icon", label: "아이콘 크기", kind: "bar" },
+  { key: "border", label: "테두리 두께", kind: "bar" },
+  { key: "number", label: "원시 수치", kind: "bar" },
+  { key: "shadow", label: "그림자 수치", kind: "value" },
+  { key: "backdrop", label: "배경 흐림", kind: "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" },
+];
+
+/**
+ * ⚠️ 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 개수를 미리 알 수 없는 토큰을
+ * 런타임에 순회해야 하는데, CSS 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서
+ * 넣는 값은 디자인 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히
+ * 토큰이 소유한다. 일반 컴포넌트에서는 이 패턴을 쓰지 않는다.
+ */
+function ref(property: keyof CSSProperties, token: TokenEntry): CSSProperties {
+  return { [property]: `var(${token.property})` } as CSSProperties;
+}
+
+function Swatches({ tokens, fn }: { tokens: TokenEntry[]; fn: string }) {
+  return (
+    <div className={styles.swatchGrid}>
+      {tokens.map((token) => (
+        <div key={token.property} className={styles.swatch}>
+          <div className={styles.swatchChip} style={ref("background", token)} />
+          <div className={styles.swatchMeta}>
+            <span className={styles.tokenName}>
+              fox.{fn}({token.name})
+            </span>
+            <span className={styles.tokenValue}>{token.value}</span>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function ValueTable({
+  tokens,
+  fn,
+  withBar,
+}: {
+  tokens: TokenEntry[];
+  fn: string;
+  withBar: boolean;
+}) {
+  return (
+    <table className={styles.table}>
+      <thead>
+        <tr>
+          <th scope="col">토큰</th>
+          <th scope="col">값</th>
+          {withBar ? <th scope="col">크기</th> : null}
+        </tr>
+      </thead>
+      <tbody>
+        {tokens.map((token) => (
+          <tr key={token.property}>
+            <td>
+              fox.{fn}({token.name})
+            </td>
+            <td className={styles.tdValue}>{token.value}</td>
+            {withBar ? (
+              <td>
+                <span className={styles.bar} style={ref("inlineSize", token)} />
+              </td>
+            ) : null}
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  );
+}
+
+export function TokenView({ group, tokens }: { group: string; tokens: TokenEntry[] }) {
+  const meta = TOKEN_GROUPS.find((entry) => entry.key === group);
+  const kind = meta?.kind ?? "value";
+
+  return (
+    <section className={styles.section}>
+      <h2 className={styles.sectionTitle}>
+        {meta?.label ?? group} · {tokens.length}
+      </h2>
+      {meta?.note ? <p className={styles.sectionNote}>{meta.note}</p> : null}
+      {kind === "swatch" ? (
+        <Swatches tokens={tokens} fn={group} />
+      ) : (
+        <ValueTable tokens={tokens} fn={group} withBar={kind === "bar"} />
+      )}
+    </section>
+  );
+}
 
@fox/dev-test/use-token-registry.ts (added)
+++ @fox/dev-test/use-token-registry.ts
@@ -0,0 +1,58 @@
+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);
+}
 
app/design/_components/theme-switch.tsx (deleted)
--- app/design/_components/theme-switch.tsx
@@ -1,80 +0,0 @@
-"use client";
-
-import { useSyncExternalStore } from "react";
-import {
-  DEFAULT_THEME_PREFERENCE,
-  THEME_ATTRIBUTE_NAME,
-  THEME_PREFERENCE_CYCLE,
-  THEME_STORAGE_KEY,
-  type ThemePreference,
-} from "@/lib/constants/theme";
-import styles from "../design.module.scss";
-
-const LABELS: Record<ThemePreference, string> = {
-  system: "시스템",
-  light: "라이트",
-  dark: "다크",
-};
-
-/**
- * `<html data-theme>` 자체가 SSOT이므로 React state를 두지 않고 DOM을 직접 구독한다
- * (앱 헤더의 테마 토글과 동일한 원칙 — 서버/클라이언트 출력이 갈리지 않는다).
- * 서버 스냅샷은 항상 기본값(system)이라 첫 렌더가 결정적이다.
- */
-function subscribe(onChange: () => void): () => void {
-  const observer = new MutationObserver(onChange);
-  observer.observe(document.documentElement, {
-    attributes: true,
-    attributeFilter: [THEME_ATTRIBUTE_NAME],
-  });
-  return () => observer.disconnect();
-}
-
-function getSnapshot(): ThemePreference {
-  const value = document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME);
-  return value === "light" || value === "dark" ? value : DEFAULT_THEME_PREFERENCE;
-}
-
-function getServerSnapshot(): ThemePreference {
-  return DEFAULT_THEME_PREFERENCE;
-}
-
-function applyTheme(next: ThemePreference): void {
-  const root = document.documentElement;
-
-  // 전환 트랜지션은 200ms 동안만 켜지는 opt-in이다(@fox/styles/_root.scss).
-  root.setAttribute("data-theme-transition", "");
-
-  if (next === "system") {
-    root.removeAttribute(THEME_ATTRIBUTE_NAME);
-    localStorage.removeItem(THEME_STORAGE_KEY);
-  } else {
-    root.setAttribute(THEME_ATTRIBUTE_NAME, next);
-    localStorage.setItem(THEME_STORAGE_KEY, next);
-  }
-
-  window.setTimeout(() => {
-    root.removeAttribute("data-theme-transition");
-  }, 200);
-}
-
-/** 카탈로그에서 라이트/다크 토큰을 눈으로 대조하기 위한 개발용 전환기. */
-export function ThemeSwitch() {
-  const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-
-  return (
-    <div className={styles.themeSwitch} role="group" aria-label="테마 전환">
-      {THEME_PREFERENCE_CYCLE.map((preference) => (
-        <button
-          key={preference}
-          type="button"
-          className={styles.themeButton}
-          aria-pressed={current === preference}
-          onClick={() => applyTheme(preference)}
-        >
-          {LABELS[preference]}
-        </button>
-      ))}
-    </div>
-  );
-}
 
app/design/_components/token-catalog.tsx (deleted)
--- app/design/_components/token-catalog.tsx
@@ -1,160 +0,0 @@
-"use client";
-
-import type { CSSProperties } from "react";
-import { useTokenRegistry } from "../_hooks/use-token-registry";
-import type { TokenEntry } from "../_lib/read-tokens";
-import { ThemeSwitch } from "./theme-switch";
-import styles from "../design.module.scss";
-
-/**
- * 렌더 방식만 다를 뿐, 목록은 전부 CSSOM에서 읽는다 — 여기에 토큰 이름을 적지 않는다.
- * `kind`는 그 그룹을 어떻게 보여줄지만 정한다: 색은 스와치, 길이는 막대, 나머지는 값만.
- */
-const GROUPS: { key: string; label: string; kind: "swatch" | "bar" | "value"; note?: string }[] = [
-  {
-    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: "화면에서 직접 쓰지 않는 바닥층입니다. 시맨틱 색상이 이 값들을 참조합니다.",
-  },
-  { key: "font", label: "서체", kind: "value", note: "size 계열은 뷰포트에 따라 값이 바뀝니다." },
-  { key: "padding", label: "패딩", kind: "bar" },
-  { key: "gap", label: "간격", kind: "bar" },
-  { key: "radius", label: "모서리 반경", kind: "bar" },
-  { key: "icon", label: "아이콘 크기", kind: "bar" },
-  { key: "border", label: "테두리 두께", kind: "bar" },
-  { key: "number", label: "원시 수치", kind: "bar" },
-  { key: "shadow", label: "그림자 수치", kind: "value" },
-  { key: "backdrop", label: "배경 흐림", kind: "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" },
-];
-
-/**
- * ⚠️ 이 파일은 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 카탈로그는 토큰
- * 레지스트리를 런타임에 순회하며 개수를 미리 알 수 없는 토큰을 렌더해야 하는데, CSS
- * 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서 인라인으로 넣는 값은 디자인
- * 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히 토큰이 소유한다.
- * 일반 화면·컴포넌트에서는 이 패턴을 쓰지 않는다.
- */
-function ref(property: keyof CSSProperties, token: TokenEntry): CSSProperties {
-  return { [property]: `var(${token.property})` } as CSSProperties;
-}
-
-function Swatches({ tokens, fn }: { tokens: TokenEntry[]; fn: string }) {
-  return (
-    <div className={styles.grid}>
-      {tokens.map((token) => (
-        <div key={token.property} className={styles.swatch}>
-          <div className={styles.swatchChip} style={ref("background", token)} />
-          <div className={styles.swatchMeta}>
-            <span className={styles.name}>
-              fox.{fn}({token.name})
-            </span>
-            <span className={styles.ref}>{token.value}</span>
-          </div>
-        </div>
-      ))}
-    </div>
-  );
-}
-
-function ValueTable({
-  tokens,
-  fn,
-  withBar,
-}: {
-  tokens: TokenEntry[];
-  fn: string;
-  withBar: boolean;
-}) {
-  return (
-    <table className={styles.table}>
-      <thead>
-        <tr>
-          <th scope="col">토큰</th>
-          <th scope="col">값</th>
-          {withBar ? <th scope="col">크기</th> : null}
-        </tr>
-      </thead>
-      <tbody>
-        {tokens.map((token) => (
-          <tr key={token.property}>
-            <td>
-              fox.{fn}({token.name})
-            </td>
-            <td className={styles.tdValue}>{token.value}</td>
-            {withBar ? (
-              <td>
-                <span className={styles.bar} style={ref("inlineSize", token)} />
-              </td>
-            ) : null}
-          </tr>
-        ))}
-      </tbody>
-    </table>
-  );
-}
-
-export function TokenCatalog() {
-  // CSSOM은 브라우저에만 있으므로 하이드레이션 이후에 채워진다. 서버 렌더 결과가
-  // 비어 있어도 개발 전용 페이지라 문제되지 않는다.
-  const registry = useTokenRegistry();
-
-  if (!registry) {
-    return (
-      <main className={styles.page}>
-        <p className={styles.lede}>토큰을 읽는 중…</p>
-      </main>
-    );
-  }
-
-  const total = Object.values(registry).reduce((sum, list) => sum + list.length, 0);
-
-  return (
-    <main className={styles.page}>
-      <header className={styles.header}>
-        <div>
-          <h1 className={styles.title}>디자인 시스템 카탈로그</h1>
-          <p className={styles.lede}>
-            @fox/styles/tokens 전량 {total}개를 CSSOM에서 읽어 그립니다. 개발 환경에서만
-            열립니다. 반응형 토큰은 현재 창 크기 기준 값입니다.
-          </p>
-        </div>
-        <ThemeSwitch />
-      </header>
-
-      {GROUPS.map(({ key, label, kind, note }) => {
-        const tokens = registry[key];
-        if (!tokens?.length) {
-          return null;
-        }
-
-        return (
-          <section key={key} className={styles.section}>
-            <h2 className={styles.sectionTitle}>
-              {label} · {tokens.length}
-            </h2>
-            {note ? <p className={styles.sectionNote}>{note}</p> : null}
-            {kind === "swatch" ? (
-              <Swatches tokens={tokens} fn={key} />
-            ) : (
-              <ValueTable tokens={tokens} fn={key} withBar={kind === "bar"} />
-            )}
-          </section>
-        );
-      })}
-    </main>
-  );
-}
 
app/design/_hooks/use-token-registry.ts (deleted)
--- app/design/_hooks/use-token-registry.ts
@@ -1,45 +0,0 @@
-import { useSyncExternalStore } from "react";
-import { readTokenRegistry, type TokenRegistry } from "../_lib/read-tokens";
-import { THEME_ATTRIBUTE_NAME } from "@/lib/constants/theme";
-
-// CSSOM은 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다
-// (effect에서 setState 하면 렌더가 연쇄된다).
-//
-// 토큰 **값**은 현재 테마에 따라 달라지므로 `data-theme`이 바뀌면 다시 읽어야 한다.
-// 반면 `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 매번 새 객체를
-// 돌려주면 무한 루프가 된다 — 그래서 테마 키로 캐시해 같은 테마 동안에는 동일한
-// 참조를 반환한다.
-let cached: { theme: string; registry: TokenRegistry } | null = null;
-
-function currentTheme(): string {
-  return document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME) ?? "system";
-}
-
-function subscribe(onChange: () => void): () => void {
-  const observer = new MutationObserver(onChange);
-  observer.observe(document.documentElement, {
-    attributes: true,
-    attributeFilter: [THEME_ATTRIBUTE_NAME],
-  });
-  return () => observer.disconnect();
-}
-
-function getSnapshot(): TokenRegistry | null {
-  const theme = currentTheme();
-  if (!cached || cached.theme !== theme) {
-    cached = { theme, registry: readTokenRegistry() };
-  }
-  return cached.registry;
-}
-
-function getServerSnapshot(): TokenRegistry | null {
-  return null;
-}
-
-/**
- * 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 토큰 레지스트리를 반환한다.
- * 테마를 바꾸면 그 테마에서 해석된 값으로 갱신된다.
- */
-export function useTokenRegistry(): TokenRegistry | null {
-  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-}
 
app/design/design.module.scss (deleted)
--- app/design/design.module.scss
@@ -1,161 +0,0 @@
-// 디자인 시스템 카탈로그 스타일.
-//
-// 토큰 목록은 이 파일이 아니라 **CSSOM에서 런타임에 읽는다**(`_components/token-catalog.tsx`).
-// SCSS 값을 JS로 내보내는 CSS Modules의 `:export`를 처음 썼으나, Next.js 16의 기본
-// 번들러인 Turbopack은 Lightning CSS로 CSS를 파싱해 `:export`(webpack css-loader 시절의
-// ICSS 확장)를 인식하지 못한다 — 빌드는 통과하지만 규칙이 통째로 버려져 값이 `undefined`가
-// 된다. `--fox-*` 커스텀 프로퍼티를 CSSOM에서 직접 열거하면 같은 목적을 번들러 의존 없이
-// 달성한다.
-
-@use "@fox/styles/abstracts" as fox;
-
-.page {
-  flex: 1;
-  padding: fox.spacing(top-lg) fox.grid(margin-default) fox.spacing(bottom-xlg);
-}
-
-.header {
-  display: flex;
-  flex-wrap: wrap;
-  gap: fox.gap(5);
-  align-items: baseline;
-  justify-content: space-between;
-  padding-block-end: fox.padding(7);
-  border-block-end: fox.border(1) solid fox.color(border-neutral-default);
-}
-
-.title {
-  font-size: fox.font-size(heading-lg);
-  font-weight: fox.font-weight(bold);
-}
-
-.lede {
-  margin-block-start: fox.gap(2);
-  color: fox.color(font-neutral-subtle);
-  font-size: fox.font-size(body-md);
-}
-
-// ── 테마 전환 ───────────────────────────────────────────────────────────────
-.themeSwitch {
-  display: flex;
-  gap: fox.gap(1);
-  padding: fox.padding(1);
-  border: fox.border(1) solid fox.color(border-neutral-default);
-  border-radius: fox.radius(max);
-  background: fox.color(surface-neutral-gray);
-}
-
-.themeButton {
-  padding: fox.padding(3) fox.padding(6);
-  border-radius: fox.radius(max);
-  color: fox.color(font-neutral-subtle);
-  font-size: fox.font-size(label-sm);
-  font-weight: fox.font-weight(medium);
-
-  &[aria-pressed="true"] {
-    background: fox.color(button-primary-surface);
-    color: fox.color(button-primary-font);
-  }
-
-  &:focus-visible {
-    outline: fox.border(2) solid fox.color(border-theme-primary);
-    outline-offset: fox.border(2);
-  }
-}
-
-// ── 섹션 ────────────────────────────────────────────────────────────────────
-.section {
-  padding-block: fox.section(spacing-xsm);
-  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
-}
-
-.sectionTitle {
-  margin-block-end: fox.gap(1);
-  font-size: fox.font-size(heading-sm);
-  font-weight: fox.font-weight(bold);
-}
-
-.sectionNote {
-  margin-block-end: fox.gap(5);
-  color: fox.color(font-neutral-subtle);
-  font-size: fox.font-size(body-sm);
-}
-
-.grid {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(fox.grid(wrap-xsm), 1fr));
-  gap: fox.gap(4);
-
-  @include fox.pc {
-    grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
-  }
-}
-
-// ── 색상 스와치 ─────────────────────────────────────────────────────────────
-.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);
-}
-
-.name {
-  display: block;
-  color: fox.color(font-neutral-default);
-  font-size: fox.font-size(label-sm);
-  font-weight: fox.font-weight(medium);
-  word-break: break-all;
-}
-
-.ref {
-  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;
-}
-
-// ── 값 테이블 ───────────────────────────────────────────────────────────────
-.table {
-  inline-size: 100%;
-  border-collapse: collapse;
-  text-align: start;
-}
-
-.table th,
-.table td {
-  padding: fox.padding(3) fox.padding(5);
-  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
-}
-
-.table th {
-  color: fox.color(font-neutral-subtle);
-  font-size: fox.font-size(label-xsm);
-  font-weight: fox.font-weight(medium);
-}
-
-.table td {
-  color: fox.color(font-neutral-default);
-  font-size: fox.font-size(body-sm);
-}
-
-.tdValue {
-  color: fox.color(font-neutral-subtle);
-}
-
-// 크기 토큰을 눈으로 비교하기 위한 막대. 폭은 인라인 style이 토큰을 참조해 넣는다.
-.bar {
-  display: block;
-  block-size: fox.icon(2);
-  border-radius: fox.radius(1);
-  background: fox.color(surface-theme-primary);
-}
 
app/design/page.tsx (deleted)
--- app/design/page.tsx
@@ -1,20 +0,0 @@
-import { notFound } from "next/navigation";
-import { TokenCatalog } from "./_components/token-catalog";
-
-export const metadata = {
-  title: "디자인 시스템 카탈로그",
-};
-
-/**
- * 디자인 시스템 토큰 카탈로그 — 개발 전용.
- *
- * 라우트 자체는 서버에 남기고(프로덕션 차단을 서버에서 판정), 실제 렌더는 CSSOM을 읽어야
- * 하는 클라이언트 컴포넌트가 맡는다.
- */
-export default function DesignCatalogPage() {
-  if (process.env.NODE_ENV === "production") {
-    notFound();
-  }
-
-  return <TokenCatalog />;
-}
Add a comment
List