임동욱 임동욱 08-20
refactor: 디자인 테스트 사이드바를 본 프로젝트 사이드바 디자인으로
admin-sidebar(시안 snb-area 3041:11860)의 규칙을 그대로 옮겼다. 240px 폭,
회색→옅은 파랑 그라데이션, 1depth 알약(multiply)·2depth 흰 카드·3depth 들여쓰기.
@fox 폴더만 복사해도 하니스가 따라가야 해서 app SCSS를 참조하지 않고 옮겨 적었다.

깊이를 맞추려고 트리도 본 프로젝트와 같은 3depth 접힘으로 바꿨다. 상위 분류가 없는
토큰 섹션(Size·Theme·Primitive)은 자식 없는 1depth라 바로 선택된다. 펼침은
useSidebarNav와 같이 "선택 항목에서 파생된 기본값 + 사용자가 누른 값" 2층이다.

시안에 없어 남긴 것: 선택 가능한 행의 개수 배지(캐럿 자리)와 focus-visible 윤곽선.

Co-Authored-By: Claude Opus 5 
@eabb484e977a0540ca79671e50a0fc8b4522c924
@fox/dev-test/dev-test-page.tsx
--- @fox/dev-test/dev-test-page.tsx
+++ @fox/dev-test/dev-test-page.tsx
@@ -1,7 +1,8 @@
 "use client";
 
-import { useSyncExternalStore } from "react";
+import { useState, useSyncExternalStore } from "react";
 import { FoxBadge } from "../core/components/fox-badge";
+import { FoxCaretDownIcon, FoxCaretUpIcon, FoxStarIcon } from "../core/icons";
 import { cx } from "../core/utils";
 import { COMPONENT_GROUPS } from "./component-groups";
 import { COMPONENT_EXAMPLES } from "./component-registry";
@@ -86,9 +87,65 @@
   return groups;
 }
 
+type TokenGroup = ReturnType<typeof groupedSections>[number];
+type ComponentGroupView = ReturnType<typeof groupedComponents>[number];
+type Selection = { kind: string; id: string };
+
+function tokenGroupId(category: string): string {
+  return `token:${category}`;
+}
+
+function componentGroupId(label: string): string {
+  return `component:${label}`;
+}
+
+/**
+ * 선택된 항목을 품은 가지 — 기본 펼침 대상이다. 사용자가 직접 누른 값이 있으면 그쪽이
+ * 이기고, 없으면 이 값을 쓴다(본 프로젝트 사이드바 `useSidebarNav`와 같은 방식).
+ */
+function defaultOpenIds(
+  current: Selection,
+  tokenGroups: TokenGroup[],
+  componentGroups: ComponentGroupView[]
+): Set<string> {
+  const ids = new Set<string>();
+
+  if (current.kind === "token") {
+    for (const group of tokenGroups) {
+      if (group.category && group.sections.some((section) => section.id === current.id)) {
+        ids.add(tokenGroupId(group.category));
+      }
+    }
+    return ids;
+  }
+
+  for (const group of componentGroups) {
+    for (const subgroup of group.subgroups) {
+      if (subgroup.examples.some((example) => example.id === current.id)) {
+        ids.add(componentGroupId(group.label));
+        ids.add(`${componentGroupId(group.label)}/${subgroup.label}`);
+      }
+    }
+  }
+
+  return ids;
+}
+
+function CountBadge({ value }: { value: number }) {
+  return (
+    <span className={styles.count}>
+      <FoxBadge type="pastel" color="neutral" size="sm">
+        {String(value)}
+      </FoxBadge>
+    </span>
+  );
+}
+
 export function DevTestPage() {
   const selection = useSelection();
   const sections = useCssTokens();
+  // 사용자가 직접 누른 것만 담는다. 없으면 선택 항목에서 파생된 기본값을 쓴다.
+  const [openOverrides, setOpenOverrides] = useState<Record<string, boolean>>({});
 
   if (!sections) {
     return (
@@ -102,103 +159,213 @@
   const current = selection ?? { kind: "token", id: sections[0]?.id ?? "" };
   const activeSection = sections.find((section) => section.id === current.id);
 
+  const tokenGroups = groupedSections(sections);
+  const componentGroups = groupedComponents();
+  const openByDefault = defaultOpenIds(current, tokenGroups, componentGroups);
+
+  const isOpen = (id: string) => openOverrides[id] ?? openByDefault.has(id);
+  const toggle = (id: string) =>
+    setOpenOverrides((prev) => ({ ...prev, [id]: !(prev[id] ?? openByDefault.has(id)) }));
+
+  const isTokenActive = (id: string) => current.kind === "token" && current.id === id;
+  const isComponentActive = (id: string) => current.kind === "component" && current.id === id;
+
   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>
-
-            {groupedSections(sections).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>
-                        <FoxBadge type="pastel" color="neutral" size="sm">
-                          {String(section.tokens.length)}
-                        </FoxBadge>
-                      </button>
-                    </li>
-                  ))}
-                </ul>
-              </div>
-            ))}
+        <div className={styles.top}>
+          <div className={styles.brand}>
+            <span className={styles.brandTitle}>@fox 디자인 시스템</span>
+            <span className={styles.brandNote}>개발 전용 테스트 페이지</span>
           </div>
 
-          <div className={styles.navGroup}>
-            <h2 className={styles.navGroupTitle}>컴포넌트 · {COMPONENT_EXAMPLES.length}</h2>
+          <ThemeSwitch />
+        </div>
 
-            {groupedComponents().map((group) => (
-              <div key={group.label} className={styles.navSubGroup}>
-                <h3 className={styles.navSubTitle}>{group.label}</h3>
+        <nav aria-label="섹션">
+          <section className={styles.section}>
+            <h2 className={styles.sectionTitle}>토큰 · {totalTokens}</h2>
 
-                {group.subgroups.map((subgroup) => (
-                  <div key={subgroup.label} className={styles.navLeaf}>
-                    <h4 className={styles.navLeafTitle}>{subgroup.label}</h4>
-                    <ul className={styles.navList}>
-                      {subgroup.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>
-                            <FoxBadge type="pastel" color="neutral" size="sm">
-                              {String(example.variants.length)}
-                            </FoxBadge>
-                          </button>
-                        </li>
-                      ))}
-                    </ul>
-                  </div>
-                ))}
-              </div>
-            ))}
+            <ul className={styles.list}>
+              {tokenGroups.map((group) => {
+                // 상위 분류가 없는 섹션은 1depth에서 바로 고른다.
+                if (!group.category) {
+                  return group.sections.map((section) => (
+                    <li key={section.id} className={styles.item}>
+                      <button
+                        type="button"
+                        className={cx(
+                          styles.itemButton,
+                          isTokenActive(section.id) && styles.itemButtonActive
+                        )}
+                        aria-current={isTokenActive(section.id) ? "page" : undefined}
+                        onClick={() => select("token", section.id)}
+                      >
+                        <span className={styles.itemLabel}>{section.label}</span>
+                        <CountBadge value={section.tokens.length} />
+                      </button>
+                    </li>
+                  ));
+                }
 
-            <ul className={styles.navList}>
+                const groupId = tokenGroupId(group.category);
+                const open = isOpen(groupId);
+                const containsActive = group.sections.some((section) =>
+                  isTokenActive(section.id)
+                );
+
+                return (
+                  <li key={groupId} className={styles.item}>
+                    <button
+                      type="button"
+                      className={cx(
+                        styles.itemButton,
+                        containsActive && styles.itemButtonActive
+                      )}
+                      aria-expanded={open}
+                      onClick={() => toggle(groupId)}
+                    >
+                      <span className={styles.itemLabel}>{group.category}</span>
+                    </button>
+
+                    {open && (
+                      <ul className={styles.childGroup}>
+                        {group.sections.map((section) => (
+                          <li key={section.id} className={styles.child}>
+                            <button
+                              type="button"
+                              className={cx(
+                                styles.childRow,
+                                isTokenActive(section.id) && styles.childRowActive
+                              )}
+                              aria-current={isTokenActive(section.id) ? "page" : undefined}
+                              onClick={() => select("token", section.id)}
+                            >
+                              <span className={cx(styles.childMark, styles.childMarkHidden)}>
+                                <FoxStarIcon />
+                              </span>
+                              <span className={styles.childLabel}>{section.label}</span>
+                              <CountBadge value={section.tokens.length} />
+                            </button>
+                          </li>
+                        ))}
+                      </ul>
+                    )}
+                  </li>
+                );
+              })}
+            </ul>
+          </section>
+
+          <section className={styles.section}>
+            <h2 className={styles.sectionTitle}>컴포넌트 · {COMPONENT_EXAMPLES.length}</h2>
+
+            <ul className={styles.list}>
+              {componentGroups.map((group) => {
+                const groupId = componentGroupId(group.label);
+                const open = isOpen(groupId);
+                const containsActive = group.subgroups.some((subgroup) =>
+                  subgroup.examples.some((example) => isComponentActive(example.id))
+                );
+
+                return (
+                  <li key={group.label} className={styles.item}>
+                    <button
+                      type="button"
+                      className={cx(
+                        styles.itemButton,
+                        containsActive && styles.itemButtonActive
+                      )}
+                      aria-expanded={open}
+                      onClick={() => toggle(groupId)}
+                    >
+                      <span className={styles.itemLabel}>{group.label}</span>
+                    </button>
+
+                    {open && (
+                      <ul className={styles.childGroup}>
+                        {group.subgroups.map((subgroup) => {
+                          const subgroupId = `${groupId}/${subgroup.label}`;
+                          const subgroupOpen = isOpen(subgroupId);
+
+                          return (
+                            <li key={subgroup.label} className={styles.child}>
+                              <button
+                                type="button"
+                                className={cx(
+                                  styles.childRow,
+                                  subgroupOpen && styles.childRowActive
+                                )}
+                                aria-expanded={subgroupOpen}
+                                onClick={() => toggle(subgroupId)}
+                              >
+                                <span className={styles.childMark}>
+                                  <FoxStarIcon />
+                                </span>
+                                <span className={styles.childLabel}>{subgroup.label}</span>
+                                <span className={styles.childMark}>
+                                  {subgroupOpen ? <FoxCaretUpIcon /> : <FoxCaretDownIcon />}
+                                </span>
+                              </button>
+
+                              {subgroupOpen && (
+                                <ul className={styles.leafGroup}>
+                                  {subgroup.examples.map((example) => (
+                                    <li key={example.id} className={styles.child}>
+                                      <button
+                                        type="button"
+                                        className={cx(
+                                          styles.leaf,
+                                          isComponentActive(example.id) && styles.leafActive
+                                        )}
+                                        aria-current={
+                                          isComponentActive(example.id) ? "page" : undefined
+                                        }
+                                        onClick={() => select("component", example.id)}
+                                      >
+                                        <span className={styles.childMark}>
+                                          <FoxStarIcon />
+                                        </span>
+                                        <span className={styles.childLabel}>{example.name}</span>
+                                        <CountBadge value={example.variants.length} />
+                                      </button>
+                                    </li>
+                                  ))}
+                                </ul>
+                              )}
+                            </li>
+                          );
+                        })}
+                      </ul>
+                    )}
+                  </li>
+                );
+              })}
+
               {/* 컴포넌트가 아니라 안내라 항상 맨 아래에 두고 생김새를 달리한다. */}
-              <li>
+              <li className={styles.item}>
                 <button
                   type="button"
-                  className={cx(styles.navItem, styles.navItemGuide)}
+                  className={cx(
+                    styles.itemButton,
+                    styles.guideButton,
+                    current.kind === "component" &&
+                      !isRegistered(current.id) &&
+                      styles.guideButtonActive
+                  )}
                   aria-current={
                     current.kind === "component" && !isRegistered(current.id) ? "page" : undefined
                   }
                   onClick={() => select("component", GUIDE_ID)}
                 >
-                  <span>추가 방법</span>
-                  <span aria-hidden="true" className={styles.navGuideMark}>
+                  <span className={styles.itemLabel}>추가 방법</span>
+                  <span aria-hidden="true" className={styles.guideMark}>
                     +
                   </span>
                 </button>
               </li>
             </ul>
-          </div>
+          </section>
         </nav>
       </aside>
 
@fox/dev-test/dev-test.module.scss
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
@@ -16,7 +16,7 @@
   @include fox.pc {
     // 화면을 넘지 않게 못박고 스크롤은 두 칸이 각자 갖는다.
     block-size: 100dvh;
-    grid-template-columns: 26rem 1fr;
+    grid-template-columns: 24rem 1fr;
     overflow: hidden;
   }
 }
@@ -28,20 +28,35 @@
 }
 
 // ── 사이드바 ────────────────────────────────────────────────────────────────
+// 본 프로젝트 사이드바(app/(protected)/(basic)/_components/admin-sidebar)와 같은 생김새다.
+// @fox 폴더만 복사해도 하니스가 따라가야 해서 규칙을 옮겨 적었다.
 .sidebar {
+  box-sizing: border-box;
   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);
+  padding: fox.padding(5);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+  background: linear-gradient(
+    to bottom,
+    fox.color(surface-neutral-gray) 70%,
+    fox.color(surface-theme-primary)
+  );
 
   @include fox.pc {
     min-block-size: 0;
     overflow-y: auto;
     border-block-end: 0;
-    border-inline-end: fox.border(1) solid fox.color(border-neutral-subtle);
+    box-shadow: inset calc(-1 * #{fox.border(1)}) 0 0 0
+      fox.color(border-neutral-subtler);
   }
+}
+
+.top {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block-end: fox.spacing(bottom-sm);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
 }
 
 .brand {
@@ -51,8 +66,11 @@
 }
 
 .brandTitle {
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(title);
   font-size: fox.font-size(heading-sm);
   font-weight: fox.font-weight(bold);
+  letter-spacing: -0.025em;
 }
 
 .brandNote {
@@ -60,124 +78,210 @@
   font-size: fox.font-size(label-xsm);
 }
 
-.nav {
+// ── 섹션 ────────────────────────────────────────────────────────────────────
+.section {
   display: flex;
+  inline-size: 100%;
   flex-direction: column;
-  gap: fox.gap(6);
+  align-items: flex-start;
+  padding-block-start: fox.spacing(top-sm);
+  padding-block-end: fox.spacing(bottom-md);
 }
 
-.navGroup {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(2);
-}
-
-.navGroupTitle {
-  padding-inline: fox.padding(3);
+.sectionTitle {
+  inline-size: 100%;
+  margin: 0;
+  padding-block-end: fox.spacing(bottom-sm);
   color: fox.color(font-neutral-subtler);
-  font-size: fox.font-size(label-xsm);
+  font-family: fox.font-family(title);
+  font-size: fox.font-size(heading-2xsm);
   font-weight: fox.font-weight(bold);
-  letter-spacing: 0.04em;
+  line-height: 1.4;
+  letter-spacing: -0.025em;
 }
 
-// Mode / Responsive 처럼 하위 항목을 묶는 분류.
-.navSubGroup {
+.list {
   display: flex;
+  inline-size: 100%;
   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;
-}
-
-// 3depth — 대분류(navSubTitle) 아래 소분류.
-.navLeaf {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(1);
-  margin-block-start: fox.gap(1);
-  padding-inline-start: fox.padding(3);
-}
-
-.navLeafTitle {
-  padding-inline: fox.padding(3);
-  color: fox.color(font-neutral-subtler);
-  font-size: fox.font-size(label-xsm);
-  font-weight: fox.font-weight(regular);
-  opacity: 0.7;
-}
-
-.navList {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(1);
+  align-items: flex-start;
   margin: 0;
   padding: 0;
   list-style: none;
 }
 
-.navItem {
+.item {
+  inline-size: 100%;
+}
+
+// ── 1depth ──────────────────────────────────────────────────────────────────
+.itemButton {
   display: flex;
   inline-size: 100%;
   align-items: center;
-  justify-content: space-between;
-  gap: fox.gap(3);
-  padding: fox.padding(3) fox.padding(4);
+  gap: fox.gap(2);
+  padding-block: fox.padding(4);
+  padding-inline: fox.padding(3);
+  border: none;
   border-radius: fox.radius(3);
+  background: fox.color(action-secondary);
   color: fox.color(font-neutral-subtle);
-  font-size: fox.font-size(label-sm);
+  font-family: fox.font-family(title);
+  font-size: 1.4rem;
+  font-weight: fox.font-weight(bold);
+  line-height: 1.4;
+  letter-spacing: -0.025em;
   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);
-  }
+  cursor: pointer;
+  mix-blend-mode: multiply;
 }
 
-// "추가 방법"은 컴포넌트가 아니라 안내다. 목록 맨 아래에 두고 점선 테두리로 구분해,
-// 등록된 컴포넌트 탭과 같은 층위로 읽히지 않게 한다.
-// `.navItem` 뒤에 와야 선택 상태(aria-current)를 덮어쓴다 — 특이도가 같아 순서가 정한다.
-.navItemGuide {
+.itemButtonActive {
+  background: fox.color(action-secondary-active);
+  color: fox.color(font-neutral-strong-inverse);
+  mix-blend-mode: normal;
+}
+
+.itemLabel {
+  flex: 1 0 0;
+  min-inline-size: 0;
+}
+
+// ── 2depth ──────────────────────────────────────────────────────────────────
+.childGroup {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  align-items: flex-start;
+  margin: fox.gap(2) 0 0;
+  padding: fox.padding(3);
+  border-radius: fox.radius(3);
+  box-shadow: inset 0 0 0 fox.border(1) fox.color(border-neutral-subtler);
+  background: fox.color(surface-neutral-default);
+  list-style: none;
+}
+
+.child {
+  inline-size: 100%;
+}
+
+.childRow {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block: fox.padding(2);
+  padding-inline: fox.padding(3);
+  border: none;
+  border-radius: fox.radius(3);
+  background: fox.color(action-secondary);
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(title);
+  font-size: 1.4rem;
+  font-weight: fox.font-weight(bold);
+  line-height: 1.4;
+  letter-spacing: -0.025em;
+  text-align: start;
+  cursor: pointer;
+}
+
+.childRowActive {
+  color: fox.color(font-theme-secondary);
+}
+
+// 별·캐럿 자리는 항상 잡아 두고 필요 없을 때 투명하게만 만든다 — 라벨 시작 위치가
+// 항목마다 흔들리지 않게 하기 위해서다.
+.childMark {
+  display: inline-flex;
+  flex-shrink: 0;
+  inline-size: fox.icon(2);
+  block-size: fox.icon(2);
+}
+
+.childMark > svg {
+  inline-size: 100%;
+  block-size: 100%;
+}
+
+.childMarkHidden {
+  opacity: 0;
+}
+
+.childLabel {
+  flex: 1 0 0;
+  min-inline-size: 0;
+}
+
+// ── 3depth ──────────────────────────────────────────────────────────────────
+.leafGroup {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  align-items: flex-start;
+  margin: 0;
+  padding-block: fox.padding(2);
+  padding-inline-start: fox.padding(7);
+  padding-inline-end: fox.padding(2);
+  border-radius: fox.radius(3);
+  list-style: none;
+}
+
+.leaf {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block: fox.padding(1);
+  padding-inline: fox.padding(2);
+  border: none;
+  background: none;
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: 1.3rem;
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+  text-align: start;
+  cursor: pointer;
+}
+
+.leafActive {
+  color: fox.color(font-theme-secondary);
+}
+
+// 시안에 없는 상태 — 키보드로 훑을 수 있어야 해서 남긴다.
+.itemButton:focus-visible,
+.childRow:focus-visible,
+.leaf:focus-visible {
+  outline: fox.border(2) solid fox.color(border-theme-primary);
+  outline-offset: fox.border(1);
+}
+
+// 시안에 없는 컴포넌트별 개수 표시. 캐럿이 서는 자리에 둔다.
+.count {
+  flex-shrink: 0;
+}
+
+// "추가 방법"은 컴포넌트가 아니라 안내다. 목록 맨 아래에 두고 테두리로 구분해, 등록된
+// 컴포넌트와 같은 층위로 읽히지 않게 한다.
+.guideButton {
   margin-block-start: fox.gap(2);
-  border: fox.border(1) dashed fox.color(border-neutral-strong);
+  box-shadow: inset 0 0 0 fox.border(1) fox.color(border-neutral-strong);
   background: none;
   color: fox.color(font-neutral-subtler);
-
-  &:hover {
-    border-color: fox.color(border-theme-primary);
-    background: none;
-    color: fox.color(font-theme-primary);
-  }
-
-  &[aria-current="page"] {
-    border-style: solid;
-    border-color: fox.color(border-theme-primary);
-    background: none;
-    color: fox.color(font-theme-primary);
-  }
+  mix-blend-mode: normal;
 }
 
-.navGuideMark {
+.guideButtonActive {
+  box-shadow: inset 0 0 0 fox.border(1) fox.color(border-theme-primary);
+  color: fox.color(font-theme-primary);
+}
+
+.guideMark {
   font-size: fox.font-size(label-lg);
   line-height: 1;
 }
 
+
 // ── 본문 ────────────────────────────────────────────────────────────────────
 .content {
   min-inline-size: 0;
Add a comment
List