임동욱 임동욱 08-11
refactor: 디자인 카탈로그를 새 토큰 체계로 재작성
18개 그룹 531개를 CSSOM에서 읽어 그린다 — 목록을 코드에 두지 않으므로 토큰을
추가하면 카탈로그에 자동으로 나타난다. 색상은 스와치, 길이는 막대, 나머지는
값으로 표시한다.

Co-Authored-By: Claude Opus 5 
@f1924f2cc7b67729eadc17ce06584729d93c783b
app/design/_components/token-catalog.tsx
--- app/design/_components/token-catalog.tsx
+++ app/design/_components/token-catalog.tsx
@@ -2,9 +2,44 @@
 
 import type { CSSProperties } from "react";
 import { useTokenRegistry } from "../_hooks/use-token-registry";
-import { typoPresetNames, type TokenEntry } from "../_lib/read-tokens";
+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`을 쓰는 **유일하게 허용되는 예외**다. 카탈로그는 토큰
@@ -17,41 +52,59 @@
   return { [property]: `var(${token.property})` } as CSSProperties;
 }
 
-function typoRef(preset: string): CSSProperties {
-  return {
-    fontSize: `var(--fox-typo-${preset}-size)`,
-    fontWeight: `var(--fox-typo-${preset}-weight)`,
-    lineHeight: `var(--fox-typo-${preset}-line-height)`,
-    letterSpacing: `var(--fox-typo-${preset}-letter-spacing)`,
-  };
+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({ rows }: { rows: { label: string; value: string }[] }) {
+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>
-        {rows.map((row) => (
-          <tr key={row.label}>
-            <td>{row.label}</td>
-            <td>{row.value}</td>
+        {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>
   );
-}
-
-function rows(group: TokenEntry[] | undefined, fn: string) {
-  return (group ?? []).map((token) => ({
-    label: `fox.${fn}(${token.name})`,
-    value: token.value,
-  }));
 }
 
 export function TokenCatalog() {
@@ -67,11 +120,7 @@
     );
   }
 
-  const colors = registry.color ?? [];
-  const spaces = registry.space ?? [];
-  const radii = registry.radius ?? [];
-  const shadows = registry.shadow ?? [];
-  const presets = typoPresetNames(registry);
+  const total = Object.values(registry).reduce((sum, list) => sum + list.length, 0);
 
   return (
     <main className={styles.page}>
@@ -79,109 +128,33 @@
         <div>
           <h1 className={styles.title}>디자인 시스템 카탈로그</h1>
           <p className={styles.lede}>
-            @fox/styles/tokens 전량을 CSSOM에서 읽어 그립니다. 개발 환경에서만 열립니다.
+            @fox/styles/tokens 전량 {total}개를 CSSOM에서 읽어 그립니다. 개발 환경에서만
+            열립니다. 반응형 토큰은 현재 창 크기 기준 값입니다.
           </p>
         </div>
         <ThemeSwitch />
       </header>
 
-      <section className={styles.section}>
-        <h2 className={styles.sectionTitle}>색상 · {colors.length}</h2>
-        <p className={styles.sectionNote}>
-          라이트/다크 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 위 전환기로 두 테마를
-          대조해 보세요.
-        </p>
-        <div className={styles.grid}>
-          {colors.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.color({token.name})</span>
-                <span className={styles.ref}>{token.value}</span>
-              </div>
-            </div>
-          ))}
-        </div>
-      </section>
+      {GROUPS.map(({ key, label, kind, note }) => {
+        const tokens = registry[key];
+        if (!tokens?.length) {
+          return null;
+        }
 
-      <section className={styles.section}>
-        <h2 className={styles.sectionTitle}>타이포 프리셋 · {presets.length}</h2>
-        <p className={styles.sectionNote}>
-          크기·굵기·행간·자간 4종이 한 묶음입니다. @include fox.typo(이름)으로 적용합니다.
-        </p>
-        {presets.map((preset) => (
-          <div key={preset} className={styles.typoRow}>
-            <span className={styles.typoName}>{preset}</span>
-            <span className={styles.typoSample} style={typoRef(preset)}>
-              다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123
-            </span>
-          </div>
-        ))}
-      </section>
-
-      <section className={styles.section}>
-        <h2 className={styles.sectionTitle}>간격 · {spaces.length}</h2>
-        <p className={styles.sectionNote}>
-          4px 배수 스케일. 1rem = 10px이므로 fox.space(4) = 1.6rem = 16px입니다.
-        </p>
-        {spaces.map((token) => (
-          <div key={token.property} className={styles.spaceRow}>
-            <span className={styles.typoName}>
-              space({token.name}) · {token.value}
-            </span>
-            <span className={styles.spaceBar} style={ref("inlineSize", token)} />
-          </div>
-        ))}
-      </section>
-
-      <section className={styles.section}>
-        <h2 className={styles.sectionTitle}>모서리 반경 · {radii.length}</h2>
-        <div className={styles.grid}>
-          {radii.map((token) => (
-            <div key={token.property} className={styles.tile} style={ref("borderRadius", token)}>
-              {token.name} · {token.value}
-            </div>
-          ))}
-        </div>
-      </section>
-
-      <section className={styles.section}>
-        <h2 className={styles.sectionTitle}>그림자 · {shadows.length}</h2>
-        <p className={styles.sectionNote}>크기가 아니라 쓰임(고도)으로 이름 붙였습니다.</p>
-        <div className={styles.grid}>
-          {shadows.map((token) => (
-            <div key={token.property} className={styles.shadowCell}>
-              <div className={styles.shadowTile} style={ref("boxShadow", token)}>
-                {token.name}
-              </div>
-            </div>
-          ))}
-        </div>
-      </section>
-
-      <section className={styles.section}>
-        <h2 className={styles.sectionTitle}>모션</h2>
-        <ValueTable
-          rows={[...rows(registry.duration, "duration"), ...rows(registry.easing, "easing")]}
-        />
-      </section>
-
-      <section className={styles.section}>
-        <h2 className={styles.sectionTitle}>서체 · 굵기 · z 층 · 브레이크포인트</h2>
-        <p className={styles.sectionNote}>
-          브레이크포인트는 미디어 쿼리가 var()를 해석하지 못하므로 실제 사용은 fox.media()
-          믹스인 경유입니다. 또한 미디어 쿼리 안의 rem은 html font-size의 영향을 받지 않아
-          48rem은 480px가 아니라 768px입니다.
-        </p>
-        <ValueTable
-          rows={[
-            ...rows(registry.font, "font"),
-            ...rows(registry.weight, "weight"),
-            ...rows(registry.z, "z"),
-            ...rows(registry.breakpoint, "media"),
-          ]}
-        />
-      </section>
+        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/_lib/read-tokens.ts
--- app/design/_lib/read-tokens.ts
+++ app/design/_lib/read-tokens.ts
@@ -1,12 +1,13 @@
 export interface TokenEntry {
-  /** 토큰 이름 (`--fox-color-primary` → `primary`) */
+  /** 그룹을 뗀 토큰 이름 (`--fox-color-background-default` → `background-default`) */
   name: string;
   /**
-   * 현재 테마에서 실제로 해석된 값 (`#818cf8`).
+   * 현재 테마·뷰포트에서 실제로 해석된 값 (`#FFFFFF`, `1.6rem`).
    *
-   * 선언 원문이 아니라 계산값을 쓴다 — Lightning CSS가 `light-dark()`를
+   * 선언 원문이 아니라 계산값을 쓴다 — 색상은 Lightning CSS가 `light-dark()`를
    * `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로
-   * 원문은 읽기 어렵고, 계산값은 지금 테마에서 눈에 보이는 색과 정확히 일치한다.
+   * 원문이 읽기 어렵고, 크기는 `alias`가 `var(--fox-…)` 참조라 원문만으로는 값을 알 수
+   * 없다. 계산값은 지금 화면에 실제로 적용되는 값과 정확히 일치한다.
    */
   value: string;
   /** 스타일에서 참조할 커스텀 프로퍼티 전체 이름 */
@@ -16,13 +17,12 @@
 export type TokenRegistry = Record<string, TokenEntry[]>;
 
 const PREFIX = "--fox-";
-const TYPO_FIELDS = ["-size", "-weight", "-line-height", "-letter-spacing"];
 
 /**
  * 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다.
  *
- * 토큰 목록을 카탈로그가 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에 값을
- * 추가하면 카탈로그에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다.
+ * 토큰 목록을 카탈로그가 따로 들고 있지 않게 하려는 것이 목적이다 — `@fox/styles/tokens/`에
+ * 값을 추가하면 카탈로그에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다.
  * CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다.
  *
  * 브라우저에서만 동작한다(문서가 있어야 한다).
@@ -59,11 +59,8 @@
           continue;
         }
 
-        const group = rest.slice(0, separator);
-        const name = rest.slice(separator + 1);
-
-        (registry[group] ??= []).push({
-          name,
+        (registry[rest.slice(0, separator)] ??= []).push({
+          name: rest.slice(separator + 1),
           value:
             computed.getPropertyValue(property).trim() ||
             rule.style.getPropertyValue(property).trim(),
@@ -74,26 +71,4 @@
   }
 
   return registry;
-}
-
-/**
- * 타이포 프리셋은 이름 하나가 4개의 커스텀 프로퍼티로 펼쳐져 있으므로
- * (`--fox-typo-title-md-size` 등) 접미사를 떼어 프리셋 이름만 순서대로 복원한다.
- */
-export function typoPresetNames(registry: TokenRegistry): string[] {
-  const names: string[] = [];
-
-  for (const entry of registry.typo ?? []) {
-    const field = TYPO_FIELDS.find((suffix) => entry.name.endsWith(suffix));
-    if (!field) {
-      continue;
-    }
-
-    const preset = entry.name.slice(0, -field.length);
-    if (!names.includes(preset)) {
-      names.push(preset);
-    }
-  }
-
-  return names;
 }
app/design/design.module.scss
--- app/design/design.module.scss
+++ app/design/design.module.scss
@@ -4,208 +4,158 @@
 // SCSS 값을 JS로 내보내는 CSS Modules의 `:export`를 처음 썼으나, Next.js 16의 기본
 // 번들러인 Turbopack은 Lightning CSS로 CSS를 파싱해 `:export`(webpack css-loader 시절의
 // ICSS 확장)를 인식하지 못한다 — 빌드는 통과하지만 규칙이 통째로 버려져 값이 `undefined`가
-// 된다. `--fox-*` 커스텀 프로퍼티를 CSSOM에서 직접 열거하면 같은 "목록을 두 곳에 두지
-// 않는다"는 목적을 번들러 의존 없이 달성한다.
+// 된다. `--fox-*` 커스텀 프로퍼티를 CSSOM에서 직접 열거하면 같은 목적을 번들러 의존 없이
+// 달성한다.
 
 @use "@fox/styles/abstracts" as fox;
 
 .page {
   flex: 1;
-  padding: fox.space(8) fox.space(6);
-
-  @include fox.container(xl, 6);
+  padding: fox.spacing(top-lg) fox.grid(margin-default) fox.spacing(bottom-xlg);
 }
 
 .header {
   display: flex;
   flex-wrap: wrap;
-  gap: fox.space(4);
+  gap: fox.gap(5);
   align-items: baseline;
   justify-content: space-between;
-  padding-block-end: fox.space(6);
-  border-block-end: 1px solid fox.color(border);
+  padding-block-end: fox.padding(7);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-default);
 }
 
 .title {
-  @include fox.typo(headline-lg);
+  font-size: fox.font-size(heading-lg);
+  font-weight: fox.font-weight(bold);
 }
 
 .lede {
-  margin-block-start: fox.space(2);
-  color: fox.color(foreground-muted);
-
-  @include fox.typo(body-md);
+  margin-block-start: fox.gap(2);
+  color: fox.color(font-neutral-subtle);
+  font-size: fox.font-size(body-md);
 }
 
 // ── 테마 전환 ───────────────────────────────────────────────────────────────
 .themeSwitch {
   display: flex;
-  gap: fox.space(1);
-  padding: fox.space(1);
-  border: 1px solid fox.color(border);
-  border-radius: fox.radius(full);
-  background: fox.color(surface-muted);
+  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.space(2) fox.space(4);
-  border-radius: fox.radius(full);
-  color: fox.color(foreground-muted);
-
-  @include fox.typo(label-md);
-  @include fox.focus-ring;
-  @include fox.transition((background-color, color));
+  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(primary);
-    color: fox.color(on-primary);
+    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.space(8);
-  border-block-end: 1px solid fox.color(border-subtle);
+  padding-block: fox.section(spacing-xsm);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
 }
 
 .sectionTitle {
-  margin-block-end: fox.space(1);
-
-  @include fox.typo(title-lg);
+  margin-block-end: fox.gap(1);
+  font-size: fox.font-size(heading-sm);
+  font-weight: fox.font-weight(bold);
 }
 
 .sectionNote {
-  margin-block-end: fox.space(5);
-  color: fox.color(foreground-muted);
-
-  @include fox.typo(body-sm);
+  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(18rem, 1fr));
-  gap: fox.space(4);
+  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: 1px solid fox.color(border);
-  border-radius: fox.radius(md);
-  background: fox.color(surface);
+  border: fox.border(1) solid fox.color(border-neutral-subtle);
+  border-radius: fox.radius(3);
+  background: fox.color(surface-neutral-default);
 }
 
 .swatchChip {
-  block-size: 6.4rem;
+  block-size: fox.icon(7);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-overlay);
 }
 
 .swatchMeta {
-  padding: fox.space(3);
+  padding: fox.padding(5);
 }
 
 .name {
   display: block;
-  color: fox.color(foreground);
+  color: fox.color(font-neutral-default);
+  font-size: fox.font-size(label-sm);
+  font-weight: fox.font-weight(medium);
   word-break: break-all;
-
-  @include fox.typo(label-lg);
 }
 
 .ref {
   display: block;
-  margin-block-start: fox.space(1);
-  color: fox.color(foreground-subtle);
-  font-family: fox.font(mono);
+  margin-block-start: fox.gap(1);
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(body-xsm);
   word-break: break-all;
-
-  @include fox.typo(body-sm);
-}
-
-// ── 타이포 ──────────────────────────────────────────────────────────────────
-.typoRow {
-  display: flex;
-  flex-wrap: wrap;
-  gap: fox.space(2) fox.space(6);
-  align-items: baseline;
-  padding-block: fox.space(3);
-  border-block-end: 1px solid fox.color(border-subtle);
-}
-
-.typoName {
-  flex: 0 0 14rem;
-  color: fox.color(foreground-subtle);
-  font-family: fox.font(mono);
-
-  @include fox.typo(body-sm);
-}
-
-.typoSample {
-  flex: 1;
-  min-inline-size: 20rem;
-}
-
-// ── 간격 ────────────────────────────────────────────────────────────────────
-.spaceRow {
-  display: flex;
-  gap: fox.space(4);
-  align-items: center;
-  padding-block: fox.space(2);
-}
-
-.spaceBar {
-  block-size: 1.6rem;
-  border-radius: fox.radius(xs);
-  background: fox.color(primary);
-}
-
-// ── radius · shadow ─────────────────────────────────────────────────────────
-.tile {
-  display: flex;
-  block-size: 9.6rem;
-  align-items: center;
-  justify-content: center;
-  border: 1px solid fox.color(border);
-  background: fox.color(surface);
-  color: fox.color(foreground-muted);
-
-  @include fox.typo(label-md);
-}
-
-.shadowTile {
-  display: flex;
-  block-size: 9.6rem;
-  align-items: center;
-  justify-content: center;
-  border-radius: fox.radius(lg);
-  background: fox.color(surface);
-  color: fox.color(foreground-muted);
-
-  @include fox.typo(label-md);
-}
-
-.shadowCell {
-  padding: fox.space(4);
 }
 
 // ── 값 테이블 ───────────────────────────────────────────────────────────────
 .table {
   inline-size: 100%;
+  border-collapse: collapse;
   text-align: start;
 }
 
 .table th,
 .table td {
-  padding: fox.space(2) fox.space(3);
-  border-block-end: 1px solid fox.color(border-subtle);
+  padding: fox.padding(3) fox.padding(5);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
 }
 
 .table th {
-  color: fox.color(foreground-muted);
-
-  @include fox.typo(label-md);
+  color: fox.color(font-neutral-subtle);
+  font-size: fox.font-size(label-xsm);
+  font-weight: fox.font-weight(medium);
 }
 
 .table td {
-  font-family: fox.font(mono);
+  color: fox.color(font-neutral-default);
+  font-size: fox.font-size(body-sm);
+}
 
-  @include fox.typo(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);
 }
Add a comment
List