임동욱 임동욱 08-18
merge: 시안 아이콘 버튼 복구(행 관리 4화면 · 테마 토글)
@33d1fe912908e43be1d46bf86266c21972f64c2f
 
app/(protected)/(basic)/_components/theme-toggle-button.module.scss (added)
+++ app/(protected)/(basic)/_components/theme-toggle-button.module.scss
@@ -0,0 +1,43 @@
+// 테마 토글 버튼 스타일.
+//
+// 아이콘 3종을 항상 DOM에 두고 CSS만으로 하나를 고른다 — 이 전환을 React state로 하면
+// 서버·클라이언트 출력이 달라져 hydration이 깨진다(선택값이 localStorage에 있기 때문).
+//
+// 원래 Tailwind의 `hidden theme-*:contents` 유틸리티가 하던 일인데, Tailwind 제거(e3d3133)
+// 이후 그 클래스들을 정의한 CSS가 사라져 세 아이콘이 한꺼번에 보이고 있었다. 같은 동작을
+// 실재하는 CSS로 다시 세운다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.icon {
+  display: none;
+  inline-size: fox.icon(4);
+  block-size: fox.icon(4);
+}
+
+// @fox 아이콘은 스스로 크기를 갖지 않는다 — 감싼 쪽이 정한다.
+.icon > svg {
+  inline-size: 100%;
+  block-size: 100%;
+}
+
+// "system"은 `<html>`에 속성이 **없는** 상태로 표현된다(lib/constants/theme).
+:global(:root:not([data-theme])) .system,
+:global(:root[data-theme="light"]) .light,
+:global(:root[data-theme="dark"]) .dark {
+  display: block;
+}
+
+// 현재 선택을 스크린리더에만 알린다. 보이는 글자가 없으므로 이 텍스트가 버튼의 접근성
+// 이름이 된다 — IconButton에 `label`을 넘기지 않는 이유다(`aria-label`은 내용을 덮어쓴다).
+.srOnly {
+  position: absolute;
+  inline-size: 1px;
+  block-size: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+  border: 0;
+}
app/(protected)/(basic)/_components/theme-toggle-button.tsx
--- app/(protected)/(basic)/_components/theme-toggle-button.tsx
+++ app/(protected)/(basic)/_components/theme-toggle-button.tsx
@@ -1,38 +1,40 @@
 import { useThemePreference } from '@/app/(protected)/(basic)/_hooks/use-theme-preference';
 import { IconButton } from '@/components/ui/icon-button';
-import { MonitorIcon } from '@/components/ui/icons/monitor-icon';
-import { MoonIcon } from '@/components/ui/icons/moon-icon';
-import { SunIcon } from '@/components/ui/icons/sun-icon';
+import {
+  FoxMonitorIcon,
+  FoxMoonIcon,
+  FoxSunIcon,
+} from '@fox/core/icons';
+import styles from './theme-toggle-button.module.scss';
 
 /**
- * 헤더 우측 테마 토글 버튼 — 아이콘 3종(모니터/해/달)을 항상 DOM에 전부 렌더하고, design이
- * globals.css에 정의한 `theme-system`/`theme-light`/`theme-dark` 커스텀 variant가 `<html
- * data-theme>` 값에 따라 CSS만으로 하나만 보이게 한다. 서버·클라이언트 출력이 항상 동일해
- * hydration 불일치가 없다 — React state로 아이콘을 고르지 않는다(계획 §4-4, §10 게이트 확정).
+ * 헤더 우측 테마 토글 버튼 — 아이콘 3종(모니터/해/달)을 항상 DOM에 전부 렌더하고,
+ * `theme-toggle-button.module.scss`가 `<html data-theme>` 값에 따라 CSS만으로 하나만 보이게
+ * 한다. 서버·클라이언트 출력이 항상 동일해 hydration 불일치가 없다 — React state로 아이콘을
+ * 고르지 않는다.
  *
- * 아이콘·라벨 쌍을 감싸는 `<span className="hidden theme-*:contents">` 래퍼에 표시 전환을
- * 건다(아이콘 자체나 `sr-only` 라벨 각각에 개별로 걸지 않는다) — `display: contents`는 자신의
- * 박스를 없애는 것이라, `sr-only`의 clip 기법이 의존하는 박스 자체가 있는 요소(라벨 span)에
- * 직접 걸면 클리핑이 무효화돼 텍스트가 그대로 노출된다. 래퍼는 그 자신의 박스만 지우고
- * 자식(아이콘의 내부 span, 라벨 span)은 각자의 박스를 그대로 유지하므로 안전하다. 아이콘
- * 컴포넌트에는 `className`을 넘기지 않는다 — svg가 아니라 이 래퍼가 표시를 전담한다.
+ * 표시 전환은 아이콘·라벨 쌍을 감싸는 래퍼에 건다(아이콘이나 `sr-only` 라벨 각각에 걸지
+ * 않는다). 숨겨진 쪽은 라벨도 함께 사라져야 스크린리더가 현재 선택 하나만 읽는다.
+ *
+ * `IconButton`에 `label`을 넘기지 않는다 — `aria-label`은 내용을 덮어쓰므로, 넘기면 테마에
+ * 따라 바뀌는 `sr-only` 텍스트가 무시된다.
  */
 export function ThemeToggleButton() {
   const { cycleThemePreference } = useThemePreference();
 
   return (
     <IconButton onClick={cycleThemePreference}>
-      <span className="hidden theme-system:contents">
-        <MonitorIcon />
-        <span className="sr-only">테마 변경 (현재: 시스템 설정)</span>
+      <span className={`${styles.icon} ${styles.system}`}>
+        <FoxMonitorIcon />
+        <span className={styles.srOnly}>테마 변경 (현재: 시스템 설정)</span>
       </span>
-      <span className="hidden theme-light:contents">
-        <SunIcon />
-        <span className="sr-only">테마 변경 (현재: 라이트)</span>
+      <span className={`${styles.icon} ${styles.light}`}>
+        <FoxSunIcon />
+        <span className={styles.srOnly}>테마 변경 (현재: 라이트)</span>
       </span>
-      <span className="hidden theme-dark:contents">
-        <MoonIcon />
-        <span className="sr-only">테마 변경 (현재: 다크)</span>
+      <span className={`${styles.icon} ${styles.dark}`}>
+        <FoxMoonIcon />
+        <span className={styles.srOnly}>테마 변경 (현재: 다크)</span>
       </span>
     </IconButton>
   );
app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
@@ -2,6 +2,8 @@
 
 import { useState, useTransition } from 'react';
 import { Button } from '@/components/ui/button';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { AdminMember } from '@/lib/domain/admin-member';
 import { deleteAdminMemberAction } from '../_actions';
@@ -25,9 +27,12 @@
  * **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server
  * Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다.
  *
- * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 두 아이콘이 아직 없다
- * (신규 아이콘 추가는 공용 컴포넌트 신설이라 design 레인 소관). 로직 우선 단계라 텍스트
- * 버튼으로 대체했고, 아이콘 부재는 별도로 보고한다.
+ * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
+ * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
+ * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
+ *
+ * 본인 계정 안내는 `<span title>`로 감싸 남긴다 — FoxIconButton은 `label`을 `aria-label`로만
+ * 쓰고 네이티브 툴팁을 렌더하지 않으며, 비활성 버튼은 스스로 hover 이벤트를 받지 못한다.
  */
 export function AdminMemberRowActions({
   member,
@@ -72,24 +77,23 @@
 
   return (
     <div className="flex items-center gap-1">
-      <Button
-        type="button"
-        variant="ghost"
+      <FoxIconButton
+        type="ghost"
         size="sm"
-        onClick={() => setIsEditOpen(true)}
-      >
-        수정
-      </Button>
-      <Button
-        type="button"
-        variant="ghost"
-        size="sm"
-        onClick={confirmDelete}
-        disabled={isSelf || isDeleting}
-        title={isSelf ? SELF_DELETE_HINT : undefined}
-      >
-        {isDeleting ? '삭제 중...' : '삭제'}
-      </Button>
+        icon={<FoxPencilSimpleIcon />}
+        label="수정"
+        onAction={() => setIsEditOpen(true)}
+      />
+      <span title={isSelf ? SELF_DELETE_HINT : undefined}>
+        <FoxIconButton
+          type="ghost"
+          size="sm"
+          icon={<FoxTrashIcon />}
+          label={isDeleting ? '삭제 중' : '삭제'}
+          disabled={isSelf || isDeleting}
+          onAction={confirmDelete}
+        />
+      </span>
 
       {isEditOpen && (
         <AdminMemberEditModal
app/(protected)/(basic)/boards/_components/board-row-actions.tsx
--- app/(protected)/(basic)/boards/_components/board-row-actions.tsx
+++ app/(protected)/(basic)/boards/_components/board-row-actions.tsx
@@ -2,6 +2,8 @@
 
 import { useState, useTransition } from 'react';
 import { Button } from '@/components/ui/button';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { BoardPost, BoardType } from '@/lib/domain/board-post';
 import { deleteBoardPostAction } from '../_actions';
@@ -20,8 +22,9 @@
  * 표시만 바꾸지만, 관리자 화면에서 다시 볼 방법이 없으므로 사용자에게는 되돌릴 수 없는 동작으로
  * 안내한다.
  *
- * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 두 아이콘이 아직 없어
- * (신규 아이콘 추가는 design 레인 소관) 텍스트 버튼으로 대체했다 — 관리자 회원 목록과 같은 처리다.
+ * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
+ * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
+ * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
  */
 export function BoardRowActions({ boardType, post }: BoardRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
@@ -63,23 +66,21 @@
 
   return (
     <div className="flex items-center gap-1">
-      <Button
-        type="button"
-        variant="ghost"
+      <FoxIconButton
+        type="ghost"
         size="sm"
-        onClick={() => setIsEditOpen(true)}
-      >
-        수정
-      </Button>
-      <Button
-        type="button"
-        variant="ghost"
+        icon={<FoxPencilSimpleIcon />}
+        label="수정"
+        onAction={() => setIsEditOpen(true)}
+      />
+      <FoxIconButton
+        type="ghost"
         size="sm"
-        onClick={confirmDelete}
+        icon={<FoxTrashIcon />}
+        label={isDeleting ? '삭제 중' : '삭제'}
         disabled={isDeleting}
-      >
-        {isDeleting ? '삭제 중...' : '삭제'}
-      </Button>
+        onAction={confirmDelete}
+      />
 
       {isEditOpen && (
         <BoardPostFormModal
app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
@@ -2,6 +2,8 @@
 
 import { useState, useTransition } from 'react';
 import { Button } from '@/components/ui/button';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { DecorationItem } from '@/lib/domain/decoration-item';
 import { deleteDecorationItemAction } from '../_actions';
@@ -21,8 +23,9 @@
  * 슬롯에 직접 [취소]/[삭제] 버튼을 넣어 구성한다. 삭제는 되돌릴 수 없는 동작이라 얼럿 문구에
  * 명시한다.
  *
- * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 아직 없어(신규 아이콘
- * 추가는 공용 컴포넌트 신설이라 design 레인 소관) admins와 동일하게 텍스트 버튼으로 대체했다.
+ * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
+ * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
+ * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
  */
 export function DecorationItemRowActions({
   item,
@@ -67,23 +70,21 @@
 
   return (
     <div className="flex items-center gap-1">
-      <Button
-        type="button"
-        variant="ghost"
+      <FoxIconButton
+        type="ghost"
         size="sm"
-        onClick={() => setIsEditOpen(true)}
-      >
-        수정
-      </Button>
-      <Button
-        type="button"
-        variant="ghost"
+        icon={<FoxPencilSimpleIcon />}
+        label="수정"
+        onAction={() => setIsEditOpen(true)}
+      />
+      <FoxIconButton
+        type="ghost"
         size="sm"
-        onClick={confirmDelete}
+        icon={<FoxTrashIcon />}
+        label={isDeleting ? '삭제 중' : '삭제'}
         disabled={isDeleting}
-      >
-        {isDeleting ? '삭제 중...' : '삭제'}
-      </Button>
+        onAction={confirmDelete}
+      />
 
       {isEditOpen && (
         <DecorationItemEditModal
app/(protected)/(basic)/students/_components/student-row-actions.tsx
--- app/(protected)/(basic)/students/_components/student-row-actions.tsx
+++ app/(protected)/(basic)/students/_components/student-row-actions.tsx
@@ -1,7 +1,8 @@
 'use client';
 
 import { useState } from 'react';
-import { Button } from '@/components/ui/button';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxMagnifyingGlassIcon } from '@fox/core/icons';
 import type { StudentMember } from '@/lib/domain/student-member';
 import { StudentDetailModal } from './student-detail-modal';
 
@@ -14,23 +15,22 @@
  * 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에
  * 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음).
  *
- * 기획 시안은 "돋보기 아이콘 버튼"을 지정하지만 `components/ui/icons/`에 아직 돋보기 아이콘이
- * 없다(신규 아이콘 추가는 공용 컴포넌트 신설이라 design 레인 소관, §4.2). 로직 우선 단계라
- * 아이콘 대신 텍스트 버튼("조회")으로 대체했고, 아이콘 부재는 별도로 보고한다.
+ * 시안의 "돋보기 아이콘 버튼"이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
+ * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로
+ * 정의하는 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
  */
 export function StudentRowActions({ member }: StudentRowActionsProps) {
   const [isDetailOpen, setIsDetailOpen] = useState(false);
 
   return (
     <>
-      <Button
-        type="button"
-        variant="ghost"
+      <FoxIconButton
+        type="ghost"
         size="sm"
-        onClick={() => setIsDetailOpen(true)}
-      >
-        조회
-      </Button>
+        icon={<FoxMagnifyingGlassIcon />}
+        label="조회"
+        onAction={() => setIsDetailOpen(true)}
+      />
       {isDetailOpen && (
         <StudentDetailModal
           member={member}
 
components/ui/icons/monitor-icon.tsx (deleted)
--- components/ui/icons/monitor-icon.tsx
@@ -1,34 +0,0 @@
-interface MonitorIconProps {
-  /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-system:contents`) —
-   * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>`
-   * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에
-   * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은
-   * 상위 요소의 currentColor를 상속한다. */
-  className?: string;
-}
-
-/**
- * 시스템(OS) 테마를 상징하는 모니터 아이콘(화면 사각형 + 받침). 직접 작도한 인라인 SVG.
- * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며,
- * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다.
- */
-export function MonitorIcon({ className }: MonitorIconProps) {
-  return (
-    <span className={className}>
-      <svg
-        viewBox="0 0 24 24"
-        fill="none"
-        stroke="currentColor"
-        strokeWidth="2"
-        strokeLinecap="round"
-        strokeLinejoin="round"
-        aria-hidden="true"
-        className="size-5"
-      >
-        <rect x="3" y="4" width="18" height="13" rx="1.5" />
-        <path d="M8 21h8" />
-        <path d="M12 17v4" />
-      </svg>
-    </span>
-  );
-}
 
components/ui/icons/moon-icon.tsx (deleted)
--- components/ui/icons/moon-icon.tsx
@@ -1,34 +0,0 @@
-interface MoonIconProps {
-  /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-dark:contents`) —
-   * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>`
-   * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에
-   * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은
-   * 상위 요소의 currentColor를 상속한다. */
-  className?: string;
-}
-
-/**
- * 다크 테마를 상징하는 초승달 아이콘(단일 path). 직접 작도한 인라인 SVG — 큰 원호(외곽)와
- * 작은 원호(안쪽 오려내기)를 이어 붙인 표준 초승달 구성법을 사용했다(외부 아이콘
- * 저작물 복제 없음).
- * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며,
- * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다.
- */
-export function MoonIcon({ className }: MoonIconProps) {
-  return (
-    <span className={className}>
-      <svg
-        viewBox="0 0 24 24"
-        fill="none"
-        stroke="currentColor"
-        strokeWidth="2"
-        strokeLinecap="round"
-        strokeLinejoin="round"
-        aria-hidden="true"
-        className="size-5"
-      >
-        <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4A6.5 6.5 0 0 0 20 14.5Z" />
-      </svg>
-    </span>
-  );
-}
 
components/ui/icons/sun-icon.tsx (deleted)
--- components/ui/icons/sun-icon.tsx
@@ -1,40 +0,0 @@
-interface SunIconProps {
-  /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-light:contents`) —
-   * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>`
-   * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에
-   * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은
-   * 상위 요소의 currentColor를 상속한다. */
-  className?: string;
-}
-
-/**
- * 라이트 테마를 상징하는 해 아이콘(중심 원 + 8방향 광선). 직접 작도한 인라인 SVG.
- * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며,
- * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다.
- */
-export function SunIcon({ className }: SunIconProps) {
-  return (
-    <span className={className}>
-      <svg
-        viewBox="0 0 24 24"
-        fill="none"
-        stroke="currentColor"
-        strokeWidth="2"
-        strokeLinecap="round"
-        strokeLinejoin="round"
-        aria-hidden="true"
-        className="size-5"
-      >
-        <circle cx="12" cy="12" r="4" />
-        <path d="M12 2v2.5" />
-        <path d="M12 19.5V22" />
-        <path d="M2 12h2.5" />
-        <path d="M19.5 12H22" />
-        <path d="M4.93 4.93l1.77 1.77" />
-        <path d="M17.3 17.3l1.77 1.77" />
-        <path d="M4.93 19.07l1.77-1.77" />
-        <path d="M17.3 6.7l1.77-1.77" />
-      </svg>
-    </span>
-  );
-}
components/ui/sidebar-group.tsx
--- components/ui/sidebar-group.tsx
+++ components/ui/sidebar-group.tsx
@@ -52,9 +52,11 @@
 
 /**
  * 펼침/접힘 표시용 셰브론(오른쪽 화살표 — 펼치면 90도 회전해 아래를 향한다). 그룹 헤딩
- * 전용 장식 요소라 components/ui/icons/에 별도 파일로 승격하지 않고 이 파일 안에 둔다.
- * moon-icon.tsx/sun-icon.tsx와 동일한 인라인 SVG 관례(stroke=currentColor로 상위 text
- * 색 토큰 상속, 장식용이라 aria-hidden).
+ * 전용 장식 요소라 이 파일 안에 둔다.
+ *
+ * ⚠️ 손으로 그린 마지막 아이콘이다. 디자인시스템에 같은 그림(`FoxCaretRightIcon`)이 있지만,
+ * 회전(`rotate-90`)과 크기(`size-4`)를 Tailwind 클래스에 기대고 있어 그 두 개를 실재하는
+ * CSS로 옮기기 전에는 바꿔도 나아지지 않는다 — components/ui 전체 리스타일과 함께 처리한다.
  */
 function ChevronIcon({ className }: { className?: string }) {
   return (
Add a comment
List