임동욱 임동욱 08-27
refactor: 남은 Tailwind를 @fox로 옮기고 스피너를 실제로 부른다
Tailwind가 프로젝트에서 빠진 뒤에도 클래스만 남아 아무 스타일도 받지 못하던 자리들이
있었고, 전역 스피너는 만들어만 두고 어디서도 호출하지 않았다. 두 손질이 같은 파일들을
건드려 한 커밋으로 둔다.

Tailwind 제거:
- components/ui의 옛 Tailwind 레이어 16개 삭제. 그중 field·icon-button만 실제로 쓰여
  각각 FoxTextArea·FoxFormLabel과 @fox 아이콘 버튼 마크업으로 옮기고 지웠다.
- error.tsx 다섯 곳이 같은 모양이라 PageError로 모은다(PageLoading과 같은 이유).
- 홈·Empty 레이아웃·게시판 첨부 링크는 module.scss로, 행 관리 버튼 묶음은 FoxButtonGroup로.
- 테마 토글은 FoxIconButton을 쓰지 못한다 — label이 필수라 aria-label이 늘 붙고, 그러면
  테마별로 바뀌는 안쪽 글자를 스크린리더가 읽지 못한다. @fox 아이콘 버튼의 마크업 계약을
  그대로 따라 같은 스타일을 받게 했다.

스피너 호출:
- useBlockingAction — 스피너를 켠 채 도는 transition. 삭제처럼 확인 얼럿이 닫힌 뒤
  진행돼 표시가 행 아이콘 하나뿐이던 자리 11곳에 붙였다. 버튼이 스스로 "저장 중"을
  보여주는 자리(중복확인·주소검색·엑셀 업로드)는 그대로 뒀다.
- 스피너 막도 토스트와 같은 이유로 popover(top layer)에 얹는다 — 모달 안에서 시작한
  동작도 덮어야 한다.
- loading.tsx를 (basic) 한 곳으로 모은다. 다섯 화면에만 있던 것을 모든 하위 화면이
  받도록 하고, 새 화면을 추가해도 자동으로 따라오게 한다.

Co-Authored-By: Claude Opus 5 
@81b90bd2729838a70662359ad01d3ea2ec95e05c
app/(protected)/(basic)/_components/theme-toggle-button.module.scss
--- app/(protected)/(basic)/_components/theme-toggle-button.module.scss
+++ app/(protected)/(basic)/_components/theme-toggle-button.module.scss
@@ -3,23 +3,22 @@
 // 아이콘 3종을 항상 DOM에 두고 CSS만으로 하나를 고른다 — 이 전환을 React state로 하면
 // 서버·클라이언트 출력이 달라져 hydration이 깨진다(선택값이 localStorage에 있기 때문).
 //
-// 원래 Tailwind의 `hidden theme-*:contents` 유틸리티가 하던 일인데, Tailwind 제거(e3d3133)
-// 이후 그 클래스들을 정의한 CSS가 사라져 세 아이콘이 한꺼번에 보이고 있었다. 같은 동작을
-// 실재하는 CSS로 다시 세운다.
+// 버튼 모양은 @fox 아이콘 버튼(ghost·md)이 갖는다. 헤더의 다른 버튼과 나란히 서므로
+// header-icon-button.module.scss와 같은 근거로 시안의 radius만 덮는다.
 
 @use "@fox/styles/abstracts" as fox;
 
-.icon {
-  display: none;
-  inline-size: fox.icon(4);
-  font-size: fox.icon(4);
-  block-size: fox.icon(4);
+// @fox 전역 스타일과 CSS 모듈은 명시도가 같아 로드 순서에 좌우된다 — `:global()`로 한 단계
+// 얹어 순서와 무관하게 이기게 한다(header-icon-button.module.scss와 같은 방식).
+.button:global(.fox-icon-button) {
+  position: relative;
+  border-radius: fox.form(radius-sm);
 }
 
-// @fox 아이콘은 스스로 크기를 갖지 않는다 — 감싼 쪽이 정한다.
-.icon > svg {
-  inline-size: 100%;
-  block-size: 100%;
+// 현재 테마에 해당하는 것만 보인다. 아이콘과 스크린리더 문구가 같은 규칙을 공유해야
+// 숨겨진 쪽의 글자까지 함께 사라진다 — 그래야 리더가 현재 선택 하나만 읽는다.
+.themeItem {
+  display: none;
 }
 
 // "system"은 `<html>`에 속성이 **없는** 상태로 표현된다(lib/constants/theme).
@@ -29,8 +28,13 @@
   display: block;
 }
 
-// 현재 선택을 스크린리더에만 알린다. 보이는 글자가 없으므로 이 텍스트가 버튼의 접근성
-// 이름이 된다 — IconButton에 `label`을 넘기지 않는 이유다(`aria-label`은 내용을 덮어쓴다).
+.icon {
+  inline-size: fox.icon(5);
+  font-size: fox.icon(5);
+  block-size: fox.icon(5);
+}
+
+// 보이는 글자가 없으므로 이 텍스트가 버튼의 접근성 이름이 된다.
 .srOnly {
   position: absolute;
   inline-size: 1px;
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,5 +1,4 @@
 import { useThemePreference } from '@/app/(protected)/(basic)/_hooks/use-theme-preference';
-import { IconButton } from '@/components/ui/icon-button';
 import styles from './theme-toggle-button.module.scss';
 
 /**
@@ -8,29 +7,41 @@
  * 한다. 서버·클라이언트 출력이 항상 동일해 hydration 불일치가 없다 — React state로 아이콘을
  * 고르지 않는다.
  *
- * 표시 전환은 아이콘·라벨 쌍을 감싸는 래퍼에 건다(아이콘이나 `sr-only` 라벨 각각에 걸지
- * 않는다). 숨겨진 쪽은 라벨도 함께 사라져야 스크린리더가 현재 선택 하나만 읽는다.
- *
- * `IconButton`에 `label`을 넘기지 않는다 — `aria-label`은 내용을 덮어쓰므로, 넘기면 테마에
- * 따라 바뀌는 `sr-only` 텍스트가 무시된다.
+ * 이슈: FoxIconButton을 쓰지 못한다 — `label`이 필수라 항상 `aria-label`이 붙고, 그러면
+ *   테마에 따라 바뀌는 안쪽 글자가 무시돼 스크린리더가 "현재: 라이트"를 읽지 못한다. 대신
+ *   @fox 아이콘 버튼의 마크업 계약(`_fox-icon-button.scss` 상단)을 그대로 따라 같은 스타일을
+ *   받는다. 헤더의 다른 버튼(HeaderIconButton)과 같은 ghost·md에 시안의 radius만 덮는다.
  */
 export function ThemeToggleButton() {
   const { cycleThemePreference } = useThemePreference();
 
   return (
-    <IconButton onClick={cycleThemePreference}>
-      <span className={`${styles.icon} ${styles.system}`}>
-        <i className="fox-ico fox-ico-Monitor" aria-hidden="true" />
-        <span className={styles.srOnly}>테마 변경 (현재: 시스템 설정)</span>
+    <button
+      type="button"
+      onClick={cycleThemePreference}
+      className={`fox-icon-button fox-icon-button--ghost fox-icon-button--md ${styles.button}`}
+    >
+      <span className="fox-icon-button__icon" aria-hidden="true">
+        <span className={`${styles.themeItem} ${styles.icon} ${styles.system}`}>
+          <i className="fox-ico fox-ico-Monitor" aria-hidden="true" />
+        </span>
+        <span className={`${styles.themeItem} ${styles.icon} ${styles.light}`}>
+          <i className="fox-ico fox-ico-Sun" aria-hidden="true" />
+        </span>
+        <span className={`${styles.themeItem} ${styles.icon} ${styles.dark}`}>
+          <i className="fox-ico fox-ico-Moon" aria-hidden="true" />
+        </span>
       </span>
-      <span className={`${styles.icon} ${styles.light}`}>
-        <i className="fox-ico fox-ico-Sun" aria-hidden="true" />
-        <span className={styles.srOnly}>테마 변경 (현재: 라이트)</span>
+
+      <span className={`${styles.themeItem} ${styles.srOnly} ${styles.system}`}>
+        테마 변경 (현재: 시스템 설정)
       </span>
-      <span className={`${styles.icon} ${styles.dark}`}>
-        <i className="fox-ico fox-ico-Moon" aria-hidden="true" />
-        <span className={styles.srOnly}>테마 변경 (현재: 다크)</span>
+      <span className={`${styles.themeItem} ${styles.srOnly} ${styles.light}`}>
+        테마 변경 (현재: 라이트)
       </span>
-    </IconButton>
+      <span className={`${styles.themeItem} ${styles.srOnly} ${styles.dark}`}>
+        테마 변경 (현재: 다크)
+      </span>
+    </button>
   );
 }
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
@@ -1,10 +1,11 @@
 'use client';
 
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import type { AdminMember } from '@/lib/domain/admin-member';
 import styles from './admin-member-row-actions.module.scss';
 import { deleteAdminMemberAction } from '../_actions';
@@ -23,7 +24,7 @@
 }: AdminMemberRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isEditOpen, setIsEditOpen] = useState(false);
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function runDelete() {
     hideAlert();
app/(protected)/(basic)/admins/error.tsx
--- app/(protected)/(basic)/admins/error.tsx
+++ app/(protected)/(basic)/admins/error.tsx
@@ -1,6 +1,7 @@
 'use client';
 
 import { useEffect } from 'react';
+import { PageError } from '@/components/ui/page-error';
 
 interface ErrorPageProps {
   error: Error & { digest?: string };
@@ -16,17 +17,5 @@
     console.error(error);
   }, [error]);
 
-  return (
-    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
-      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
-      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
-      <button
-        type="button"
-        onClick={() => unstable_retry()}
-        className="mt-4 bg-foreground px-4 py-2 text-background"
-      >
-        다시 시도
-      </button>
-    </section>
-  );
+  return <PageError onRetry={unstable_retry} />;
 }
 
app/(protected)/(basic)/admins/loading.tsx (deleted)
--- app/(protected)/(basic)/admins/loading.tsx
@@ -1,6 +0,0 @@
-import { PageLoading } from '@/components/ui/page-loading';
-
-// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다.
-export default function Loading() {
-  return <PageLoading />;
-}
app/(protected)/(basic)/admins/menus/_components/admin-menu-list.tsx
--- app/(protected)/(basic)/admins/menus/_components/admin-menu-list.tsx
+++ app/(protected)/(basic)/admins/menus/_components/admin-menu-list.tsx
@@ -1,6 +1,6 @@
 'use client';
 
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
@@ -9,6 +9,7 @@
   type FoxListColumn,
 } from '@fox/core/components/fox-list-container';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import {
   flattenAdminMenuTree,
   formatActiveLabel,
@@ -51,7 +52,7 @@
     new Set()
   );
   const [modal, setModal] = useState<ModalState | null>(null);
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   const rows = flattenAdminMenuTree(roots, collapsedIds);
 
app/(protected)/(basic)/boards/_components/board-attachment-field.module.scss
--- app/(protected)/(basic)/boards/_components/board-attachment-field.module.scss
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.module.scss
@@ -26,3 +26,16 @@
   font-family: fox.font-family(body);
   font-size: fox.font-size(body-sm);
 }
+
+// 라벨과 입력을 세로로 쌓는다 — `<label>`이 파일 입력을 함께 감싸 암시적으로 연결된다.
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.download {
+  color: fox.color(font-theme-primary);
+  text-decoration: underline;
+  text-underline-offset: 0.2rem;
+}
app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
--- app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
@@ -1,8 +1,8 @@
 'use client';
 
 import { useState } from 'react';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
 import { FoxHelperText } from '@fox/core/components/fox-helper-text';
-import { Field } from '@/components/ui/field';
 import styles from './board-attachment-field.module.scss';
 import {
   FILE_MODULE,
@@ -46,7 +46,8 @@
   const [error, setError] = useState<string | undefined>(undefined);
 
   return (
-    <Field label={label}>
+    <label className={styles.field}>
+      <FoxFormLabel as="span">{label}</FoxFormLabel>
       {/* 기존 첨부 id를 함께 제출해 "파일을 안 골랐으면 유지"를 서버가 판단할 수 있게 한다. */}
       <input type="hidden" name="attachmentId" value={attachmentId ?? ''} />
 
@@ -55,7 +56,7 @@
           기존 첨부파일{' '}
           <a
             href={`${ATTACHMENT_DOWNLOAD_PATH}?atchFileId=${encodeURIComponent(attachmentId)}`}
-            className="text-primary underline underline-offset-2"
+            className={styles.download}
           >
             다운로드
           </a>{' '}
@@ -90,6 +91,6 @@
         }}
       />
       {error && <FoxHelperText type="danger" message={error} />}
-    </Field>
+    </label>
   );
 }
app/(protected)/(basic)/boards/_components/board-content-field.tsx
--- app/(protected)/(basic)/boards/_components/board-content-field.tsx
+++ app/(protected)/(basic)/boards/_components/board-content-field.tsx
@@ -1,7 +1,6 @@
 'use client';
 
-import { useState } from 'react';
-import { Field } from '@/components/ui/field';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
 
 interface BoardContentFieldProps {
   name: string;
@@ -28,22 +27,15 @@
   maxLength,
   placeholder,
 }: BoardContentFieldProps) {
-  const [length, setLength] = useState(defaultValue.length);
-
   return (
-    <Field label={label}>
-      <textarea
-        name={name}
-        defaultValue={defaultValue}
-        maxLength={maxLength}
-        placeholder={placeholder}
-        rows={6}
-        onChange={(event) => setLength(event.target.value.length)}
-        className="w-full rounded-md border border-border bg-surface px-3 py-2 text-body-md text-foreground placeholder:text-foreground-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
-      />
-      <p className="text-right text-body-sm text-foreground-muted">
-        {length}/{maxLength}자
-      </p>
-    </Field>
+    <FoxTextArea
+      name={name}
+      label={label}
+      defaultValue={defaultValue}
+      maxLength={maxLength}
+      placeholder={placeholder}
+      rows={6}
+      counter
+    />
   );
 }
app/(protected)/(basic)/boards/_components/board-post-list.tsx
--- app/(protected)/(basic)/boards/_components/board-post-list.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-list.tsx
@@ -1,7 +1,8 @@
 'use client';
 
 import { useRouter } from 'next/navigation';
-import { useState, useTransition, type ReactNode } from 'react';
+import { useState, type ReactNode } from 'react';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { FoxListContainer } from '@fox/core/components/fox-list-container';
@@ -60,7 +61,7 @@
 }: BoardPostListProps) {
   const router = useRouter();
   const [isCreateOpen, setIsCreateOpen] = useState(false);
-  const [, startRefresh] = useTransition();
+  const [, startRefresh] = useBlockingAction();
 
   function go(patch: Partial<BoardPostQuery>) {
     router.replace(buildBoardPostHref(boardType, query, { ...patch, page: 1 }));
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
@@ -1,10 +1,11 @@
 'use client';
 
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import type { BoardPost, BoardType } from '@/lib/domain/board-post';
 import type { CommonCode } from '@/lib/domain/common-code';
 import { deleteBoardPostAction, loadBoardPostAction } from '../_actions';
@@ -37,8 +38,8 @@
   const { showAlert, hideAlert, showToast } = useFeedback();
   // 목록 행이 아니라 단건 조회 결과로 폼을 채운다 — 행은 화면에 떠 있는 동안 낡을 수 있다.
   const [editPost, setEditPost] = useState<BoardPost | null>(null);
-  const [isLoading, startLoading] = useTransition();
-  const [isDeleting, startDeleting] = useTransition();
+  const [isLoading, startLoading] = useBlockingAction();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function openEdit() {
     startLoading(async () => {
@@ -81,23 +82,25 @@
   }
 
   return (
-    <div className="flex items-center gap-1">
-      <FoxIconButton
-        type="default"
-        size="sm"
-        icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
-        label={isLoading ? '불러오는 중' : '수정'}
-        disabled={isLoading}
-        onAction={openEdit}
-      />
-      <FoxIconButton
-        type="default"
-        size="sm"
-        icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
-        label={isDeleting ? '삭제 중' : '삭제'}
-        disabled={isDeleting}
-        onAction={confirmDelete}
-      />
+    <>
+      <FoxButtonGroup size="sm">
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
+          label={isLoading ? '불러오는 중' : '수정'}
+          disabled={isLoading}
+          onAction={openEdit}
+        />
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
+          label={isDeleting ? '삭제 중' : '삭제'}
+          disabled={isDeleting}
+          onAction={confirmDelete}
+        />
+      </FoxButtonGroup>
 
       {editPost && (
         <BoardPostFormModal
@@ -108,6 +111,6 @@
           onClose={() => setEditPost(null)}
         />
       )}
-    </div>
+    </>
   );
 }
app/(protected)/(basic)/boards/error.tsx
--- app/(protected)/(basic)/boards/error.tsx
+++ app/(protected)/(basic)/boards/error.tsx
@@ -1,32 +1,21 @@
 'use client';
 
 import { useEffect } from 'react';
+import { PageError } from '@/components/ui/page-error';
 
 interface ErrorPageProps {
   error: Error & { digest?: string };
   unstable_retry: () => void;
 }
 
-// 게시판 세 화면이 공유하는 에러 경계. 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만
-// 콘솔에 남기고(프로덕션에서는 Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로
-// 치환해 전달한다), 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라
-// 세그먼트 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
+// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는
+// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다),
+// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트
+// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
 export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
   useEffect(() => {
     console.error(error);
   }, [error]);
 
-  return (
-    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
-      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
-      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
-      <button
-        type="button"
-        onClick={() => unstable_retry()}
-        className="mt-4 bg-foreground px-4 py-2 text-background"
-      >
-        다시 시도
-      </button>
-    </section>
-  );
+  return <PageError onRetry={unstable_retry} />;
 }
app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
@@ -1,10 +1,11 @@
 'use client';
 
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import { formatContentExcerpt, type BoardPost } from '@/lib/domain/board-post';
 import type { CommonCode } from '@/lib/domain/common-code';
 import { deleteBoardPostAction, loadBoardPostAction } from '../../_actions';
@@ -28,8 +29,8 @@
   const { showAlert, hideAlert, showToast } = useFeedback();
   // 목록 행이 아니라 단건 조회 결과로 채운다 — 답변 중에 남이 고쳤을 수 있다.
   const [answerPost, setAnswerPost] = useState<BoardPost | null>(null);
-  const [isLoading, startLoading] = useTransition();
-  const [isDeleting, startDeleting] = useTransition();
+  const [isLoading, startLoading] = useBlockingAction();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function openAnswer() {
     startLoading(async () => {
 
app/(protected)/(basic)/boards/loading.tsx (deleted)
--- app/(protected)/(basic)/boards/loading.tsx
@@ -1,6 +0,0 @@
-import { PageLoading } from '@/components/ui/page-loading';
-
-// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다.
-export default function Loading() {
-  return <PageLoading />;
-}
app/(protected)/(basic)/contents/_components/content-row-actions.tsx
--- app/(protected)/(basic)/contents/_components/content-row-actions.tsx
+++ app/(protected)/(basic)/contents/_components/content-row-actions.tsx
@@ -1,11 +1,11 @@
 'use client';
 
-import { useTransition } from 'react';
 import { useRouter } from 'next/navigation';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import type { ContentKind } from '@/lib/domain/content';
 import { contentEditPath } from '@/lib/domain/content-query';
 import { deleteContentAction } from '../_actions';
@@ -20,7 +20,7 @@
 export function ContentRowActions({ kind, id, name }: ContentRowActionsProps) {
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function runDelete() {
     hideAlert();
app/(protected)/(basic)/contents/error.tsx
--- app/(protected)/(basic)/contents/error.tsx
+++ app/(protected)/(basic)/contents/error.tsx
@@ -1,32 +1,21 @@
 'use client';
 
 import { useEffect } from 'react';
+import { PageError } from '@/components/ui/page-error';
 
 interface ErrorPageProps {
   error: Error & { digest?: string };
   unstable_retry: () => void;
 }
 
-// 게시판 세 화면이 공유하는 에러 경계. 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만
-// 콘솔에 남기고(프로덕션에서는 Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로
-// 치환해 전달한다), 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라
-// 세그먼트 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
+// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는
+// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다),
+// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트
+// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
 export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
   useEffect(() => {
     console.error(error);
   }, [error]);
 
-  return (
-    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
-      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
-      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
-      <button
-        type="button"
-        onClick={() => unstable_retry()}
-        className="mt-4 bg-foreground px-4 py-2 text-background"
-      >
-        다시 시도
-      </button>
-    </section>
-  );
+  return <PageError onRetry={unstable_retry} />;
 }
 
app/(protected)/(basic)/contents/loading.tsx (deleted)
--- app/(protected)/(basic)/contents/loading.tsx
@@ -1,6 +0,0 @@
-import { PageLoading } from '@/components/ui/page-loading';
-
-// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다.
-export default function Loading() {
-  return <PageLoading />;
-}
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
@@ -1,10 +1,11 @@
 'use client';
 
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import type { CommonCode } from '@/lib/domain/common-code';
 import type { DecorationItem } from '@/lib/domain/decoration-item';
 import { deleteDecorationItemAction } from '../_actions';
@@ -31,7 +32,7 @@
 }: DecorationItemRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isEditOpen, setIsEditOpen] = useState(false);
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function runDelete() {
     hideAlert();
app/(protected)/(basic)/decoration-items/error.tsx
--- app/(protected)/(basic)/decoration-items/error.tsx
+++ app/(protected)/(basic)/decoration-items/error.tsx
@@ -1,6 +1,7 @@
 'use client';
 
 import { useEffect } from 'react';
+import { PageError } from '@/components/ui/page-error';
 
 interface ErrorPageProps {
   error: Error & { digest?: string };
@@ -10,23 +11,11 @@
 // 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는
 // Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다),
 // 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트
-// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다(admins/error.tsx와 동일).
+// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
 export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
   useEffect(() => {
     console.error(error);
   }, [error]);
 
-  return (
-    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
-      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
-      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
-      <button
-        type="button"
-        onClick={() => unstable_retry()}
-        className="mt-4 bg-foreground px-4 py-2 text-background"
-      >
-        다시 시도
-      </button>
-    </section>
-  );
+  return <PageError onRetry={unstable_retry} />;
 }
 
app/(protected)/(basic)/decoration-items/loading.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/loading.tsx
@@ -1,6 +0,0 @@
-import { PageLoading } from '@/components/ui/page-loading';
-
-// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다.
-export default function Loading() {
-  return <PageLoading />;
-}
 
app/(protected)/(basic)/home.module.scss (added)
+++ app/(protected)/(basic)/home.module.scss
@@ -0,0 +1,29 @@
+@use "@fox/styles/abstracts" as fox;
+
+// 아직 내용이 없는 홈 — 콘텐츠 자리를 그대로 차지하고 가운데에 안내만 둔다.
+.root {
+  display: flex;
+  flex: 1 0 0;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: fox.gap(2);
+  padding: fox.padding(9);
+}
+
+.greeting {
+  margin: 0;
+  color: fox.color(font-neutral-strong);
+  font-family: fox.font-family(title);
+  font-size: fox.font-size(heading-sm);
+  font-weight: fox.font-weight(bold);
+  line-height: 1.5;
+}
+
+.description {
+  margin: 0;
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-md);
+  line-height: 1.5;
+}
 
app/(protected)/(basic)/loading.tsx (added)
+++ app/(protected)/(basic)/loading.tsx
@@ -0,0 +1,9 @@
+import { PageLoading } from '@/components/ui/page-loading';
+
+/**
+ * Basic 레이아웃 아래 모든 화면의 스트리밍 폴백. 세그먼트마다 두지 않고 여기 한 곳에 둔다 —
+ * 새 화면을 추가해도 자동으로 같은 폴백을 받는다.
+ */
+export default function Loading() {
+  return <PageLoading />;
+}
app/(protected)/(basic)/page.tsx
--- app/(protected)/(basic)/page.tsx
+++ app/(protected)/(basic)/page.tsx
@@ -1,12 +1,13 @@
 import { verifySession } from '@/lib/auth/dal';
+import styles from './home.module.scss';
 
 export default async function Page() {
   const admin = await verifySession();
 
   return (
-    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
-      <p className="text-lg text-foreground">{admin.name}님, 환영합니다.</p>
-      <p className="text-sm text-foreground/60">준비 중인 화면입니다.</p>
+    <section className={styles.root}>
+      <p className={styles.greeting}>{admin.name}님, 환영합니다.</p>
+      <p className={styles.description}>준비 중인 화면입니다.</p>
     </section>
   );
 }
app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
--- app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
+++ app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
@@ -1,10 +1,11 @@
 'use client';
 
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import type { PointStandard } from '@/lib/domain/point-standard';
 import type { CommonCode } from '@/lib/domain/common-code';
 import { deletePointStandardAction } from '../_actions';
@@ -21,7 +22,7 @@
 }: PointStandardRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isEditOpen, setIsEditOpen] = useState(false);
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function runDelete() {
     hideAlert();
@@ -50,22 +51,24 @@
   }
 
   return (
-    <div className="flex items-center gap-1">
-      <FoxIconButton
-        type="default"
-        size="sm"
-        icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
-        label="수정"
-        onAction={() => setIsEditOpen(true)}
-      />
-      <FoxIconButton
-        type="default"
-        size="sm"
-        icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
-        label={isDeleting ? '삭제 중' : '삭제'}
-        disabled={isDeleting}
-        onAction={confirmDelete}
-      />
+    <>
+      <FoxButtonGroup size="sm">
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
+          label="수정"
+          onAction={() => setIsEditOpen(true)}
+        />
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
+          label={isDeleting ? '삭제 중' : '삭제'}
+          disabled={isDeleting}
+          onAction={confirmDelete}
+        />
+      </FoxButtonGroup>
 
       {isEditOpen && (
         <PointStandardFormModal
@@ -74,6 +77,6 @@
           onClose={() => setIsEditOpen(false)}
         />
       )}
-    </div>
+    </>
   );
 }
app/(protected)/(basic)/students/error.tsx
--- app/(protected)/(basic)/students/error.tsx
+++ app/(protected)/(basic)/students/error.tsx
@@ -1,6 +1,7 @@
 'use client';
 
 import { useEffect } from 'react';
+import { PageError } from '@/components/ui/page-error';
 
 interface ErrorPageProps {
   error: Error & { digest?: string };
@@ -16,17 +17,5 @@
     console.error(error);
   }, [error]);
 
-  return (
-    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
-      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
-      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
-      <button
-        type="button"
-        onClick={() => unstable_retry()}
-        className="mt-4 bg-foreground px-4 py-2 text-background"
-      >
-        다시 시도
-      </button>
-    </section>
-  );
+  return <PageError onRetry={unstable_retry} />;
 }
 
app/(protected)/(basic)/students/loading.tsx (deleted)
--- app/(protected)/(basic)/students/loading.tsx
@@ -1,6 +0,0 @@
-import { PageLoading } from '@/components/ui/page-loading';
-
-// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다.
-export default function Loading() {
-  return <PageLoading />;
-}
app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx
--- app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx
+++ app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx
@@ -1,7 +1,7 @@
 'use client';
 
 import { useRouter } from 'next/navigation';
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
@@ -11,6 +11,7 @@
 } from '@fox/core/components/fox-list-container';
 import { FoxSelectText } from '@fox/core/components/fox-select-text';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import { formatOptionalValue, type BannedWord } from '@/lib/domain/banned-word';
 import {
   BANNED_WORD_NEW_PATH,
@@ -40,7 +41,7 @@
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isExcelOpen, setIsExcelOpen] = useState(false);
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function go(patch: Partial<BannedWordQuery>) {
     router.replace(buildBannedWordHref(query, { ...patch, page: 1 }));
app/(protected)/(basic)/system/codes/_components/code-management.tsx
--- app/(protected)/(basic)/system/codes/_components/code-management.tsx
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
@@ -1,7 +1,7 @@
 'use client';
 
 import { useRouter } from 'next/navigation';
-import { useState, useTransition } from 'react';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxHeadingGroup } from '@fox/core/components/fox-heading-group';
 import {
@@ -10,6 +10,7 @@
 } from '@fox/core/components/fox-list-container';
 import { FoxPageHeader } from '@fox/core/components/fox-page-header';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import {
   formatOptionalText,
   type CommonCodeDetail,
@@ -91,7 +92,7 @@
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [modal, setModal] = useState<OpenModal>(null);
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function go(patch: Partial<CommonCodeQuery>) {
     router.replace(buildCommonCodeHref(query, patch));
app/(protected)/(basic)/system/menus/_components/menu-row-actions.tsx
--- app/(protected)/(basic)/system/menus/_components/menu-row-actions.tsx
+++ app/(protected)/(basic)/system/menus/_components/menu-row-actions.tsx
@@ -1,10 +1,10 @@
 'use client';
 
-import { useTransition } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import type { SystemMenuNode } from '@/lib/domain/system-menu';
 import { deleteSystemMenuAction } from '../_actions';
 import styles from './menu-tree.module.scss';
@@ -17,7 +17,7 @@
 
 export function MenuRowActions({ node, onEdit, onAddChild }: MenuRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
-  const [isDeleting, startDeleting] = useTransition();
+  const [isDeleting, startDeleting] = useBlockingAction();
 
   function runDelete() {
     hideAlert();
 
app/(protected)/(empty)/layout.module.scss (added)
+++ app/(protected)/(empty)/layout.module.scss
@@ -0,0 +1,6 @@
+// Empty 레이아웃 셸 — 헤더·사이드바 없이 화면 전체를 세로로 쓴다.
+.root {
+  display: flex;
+  min-block-size: 100vh;
+  flex-direction: column;
+}
app/(protected)/(empty)/layout.tsx
--- app/(protected)/(empty)/layout.tsx
+++ app/(protected)/(empty)/layout.tsx
@@ -1,3 +1,5 @@
+import styles from './layout.module.scss';
+
 // Empty 레이아웃 — 헤더·사이드바 없이 전체 화면을 쓰는 보호 화면 진입점. 보안 강제는 상위
 // `(protected)/layout.tsx`가 담당하므로(세션 재검증·bfcache 방어 상속) 여기서는 크롬을 두지 않는다.
 // 이 그룹에는 아직 화면(page.tsx)이 없다 — 최초 Empty 화면 작업 시 이 레이아웃 아래에 추가한다.
@@ -6,5 +8,5 @@
 }: Readonly<{
   children: React.ReactNode;
 }>) {
-  return <div className="flex min-h-screen flex-col">{children}</div>;
+  return <div className={styles.root}>{children}</div>;
 }
app/_components/feedback-host.module.scss
--- app/_components/feedback-host.module.scss
+++ app/_components/feedback-host.module.scss
@@ -47,12 +47,33 @@
   }
 }
 
-.spinnerOverlay {
+// 스피너 막도 같은 이유로 top layer에 얹는다 — 모달 안에서 시작한 저장·삭제도 덮어야 한다.
+// 토스트와 달리 필요할 때만 DOM에 붙인다(막이 늘 떠 있으면 안 되므로).
+.spinnerLayer {
   position: fixed;
   inset: 0;
-  z-index: 50;
+  inline-size: auto;
+  max-inline-size: none;
+  block-size: auto;
+  max-block-size: none;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  overflow: hidden;
+  background: fox.color(background-dim);
+}
+
+.spinnerLayer:popover-open {
   display: flex;
   align-items: center;
   justify-content: center;
-  background: fox.color(background-dim);
+}
+
+@supports not selector(:popover-open) {
+  .spinnerLayer {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 50;
+  }
 }
app/_components/feedback-host.tsx
--- app/_components/feedback-host.tsx
+++ app/_components/feedback-host.tsx
@@ -19,7 +19,9 @@
 export function FeedbackHost() {
   const { toasts, alert, spinnerCount, hideAlert } = useFeedback();
   const toastLayerRef = useRef<HTMLDivElement>(null);
+  const spinnerLayerRef = useRef<HTMLDivElement>(null);
   const hasToasts = toasts.length > 0;
+  const isBusy = spinnerCount > 0;
 
   // 모달 위에서도 보이도록 top layer에 얹는다(feedback-host.module.scss의 이슈 주석 참고).
   useEffect(() => {
@@ -35,6 +37,16 @@
       layer.hidePopover();
     }
   }, [hasToasts]);
+
+  useEffect(() => {
+    const layer = spinnerLayerRef.current;
+    if (!layer || typeof layer.showPopover !== 'function') {
+      return;
+    }
+    if (!layer.matches(':popover-open')) {
+      layer.showPopover();
+    }
+  }, [isBusy]);
 
   return (
     <>
@@ -69,8 +81,8 @@
         </FoxModal>
       )}
 
-      {spinnerCount > 0 && (
-        <div className={styles.spinnerOverlay}>
+      {isBusy && (
+        <div ref={spinnerLayerRef} popover="manual" className={styles.spinnerLayer}>
           <Spinner size="lg" tone="inverse" />
         </div>
       )}
 
app/_hooks/use-blocking-action.ts (added)
+++ app/_hooks/use-blocking-action.ts
@@ -0,0 +1,35 @@
+'use client';
+
+import { useTransition } from 'react';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+
+/**
+ * 화면이 기다려야 하는 동작을 전역 스피너와 함께 돌린다 — `useTransition`을 그대로 쓰되
+ * 진행 중 표시를 붙인 것이다.
+ *
+ * 삭제처럼 확인 얼럿이 닫힌 뒤에 진행되는 동작은 표시가 행 아이콘 하나뿐이라 눌렸는지조차
+ * 알기 어렵다. 그런 자리에 쓴다 — 버튼이 스스로 "저장 중"을 보여주는 자리에는 쓰지 않는다.
+ *
+ * 스피너를 켜는 것은 transition 밖에서 한다. 안에서 켜면 낮은 우선순위로 밀려 늦게 그려진다.
+ * 끄는 것은 안에서 해야 갱신된 화면이 준비될 때까지 스피너가 남는다.
+ */
+export function useBlockingAction(): [
+  boolean,
+  (action: () => void | Promise<void>) => void,
+] {
+  const { showSpinner, hideSpinner } = useFeedback();
+  const [isPending, startTransition] = useTransition();
+
+  function run(action: () => void | Promise<void>) {
+    showSpinner();
+    startTransition(async () => {
+      try {
+        await action();
+      } finally {
+        hideSpinner();
+      }
+    });
+  }
+
+  return [isPending, run];
+}
 
components/ui/alert.tsx (deleted)
--- components/ui/alert.tsx
@@ -1,39 +0,0 @@
-import type { ReactNode } from "react";
-
-type AlertVariant = "info" | "danger";
-
-const TITLE_TONE_CLASSES: Record<AlertVariant, string> = {
-  info: "text-foreground",
-  danger: "text-danger",
-};
-
-interface AlertProps {
-  variant?: AlertVariant;
-  title: string;
-  children?: ReactNode;
-  actions?: ReactNode;
-}
-
-/**
- * 확인/경고 다이얼로그 비주얼(scrim 오버레이 + 패널).
- * 열림/닫힘 여부는 호출부(feature)가 마운트/언마운트로 제어하고, 포커스 트랩도
- * 호출부 책임이다. 이 컴포넌트는 상태를 가지지 않는다.
- */
-export function Alert({ variant = "info", title, children, actions }: AlertProps) {
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-scrim p-4">
-      <div
-        role="alertdialog"
-        aria-modal="true"
-        aria-label={title}
-        className="w-full max-w-sm animate-dialog-in rounded-xl bg-surface p-6 shadow-dialog"
-      >
-        <h2 className={`text-title-lg ${TITLE_TONE_CLASSES[variant]}`}>{title}</h2>
-        {children && (
-          <div className="mt-2 text-body-md text-foreground-muted">{children}</div>
-        )}
-        {actions && <div className="mt-6 flex justify-end gap-2">{actions}</div>}
-      </div>
-    </div>
-  );
-}
 
components/ui/auth-card.tsx (deleted)
--- components/ui/auth-card.tsx
@@ -1,29 +0,0 @@
-import Image from "next/image";
-import type { ReactNode } from "react";
-
-interface AuthCardProps {
-  title?: string;
-  children: ReactNode;
-}
-
-/** primary 배경 전면 위 중앙 카드(로그인 등 인증 화면용). */
-export function AuthCard({ title, children }: AuthCardProps) {
-  return (
-    <div className="flex min-h-screen items-center justify-center bg-primary p-6">
-      <div className="w-full max-w-sm rounded-2xl bg-surface p-8 shadow-list-card">
-        <Image
-          src="/images/logo-foxpay.svg"
-          alt="FoxPay"
-          width={170}
-          height={62}
-          className="mx-auto h-10 w-auto"
-          priority
-        />
-        {title && (
-          <h1 className="mt-6 text-center text-title-lg text-foreground">{title}</h1>
-        )}
-        <div className="mt-6">{children}</div>
-      </div>
-    </div>
-  );
-}
 
components/ui/button.tsx (deleted)
--- components/ui/button.tsx
@@ -1,44 +0,0 @@
-import type { ComponentProps } from "react";
-
-type ButtonVariant = "primary" | "secondary" | "success" | "ghost";
-type ButtonSize = "sm" | "md";
-
-const VARIANT_CLASSES: Record<ButtonVariant, string> = {
-  primary: "bg-primary text-on-primary hover:opacity-90",
-  secondary: "border border-border bg-surface text-foreground hover:bg-surface-muted",
-  // on-success 토큰이 아직 없어(§10.4 대상) primary 필렁 버튼과 동일하게 on-primary를
-  // 재사용한다 — 강조색(엑셀 다운로드 등) 액션용.
-  success: "bg-success text-on-primary hover:opacity-90",
-  ghost: "text-foreground-muted hover:bg-surface-muted hover:text-foreground",
-};
-
-const SIZE_CLASSES: Record<ButtonSize, string> = {
-  sm: "h-8 px-3 text-body-sm",
-  md: "h-10 px-4 text-body-md",
-};
-
-interface ButtonProps extends ComponentProps<"button"> {
-  variant?: ButtonVariant;
-  size?: ButtonSize;
-}
-
-/**
- * 공용 버튼. 상태를 가지지 않는 프레젠테이션 컴포넌트 — 클릭 동작·로딩 처리는
- * 호출부(feature) 책임이다. `type` 기본값은 `button`(폼 안에서 의도치 않은
- * submit 방지) — 제출 버튼은 명시적으로 `type="submit"`을 전달한다.
- */
-export function Button({
-  variant = "primary",
-  size = "md",
-  type = "button",
-  className,
-  ...props
-}: ButtonProps) {
-  return (
-    <button
-      type={type}
-      className={`inline-flex items-center justify-center gap-2 rounded-md transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className ?? ""}`}
-      {...props}
-    />
-  );
-}
 
components/ui/field.tsx (deleted)
--- components/ui/field.tsx
@@ -1,21 +0,0 @@
-import type { ReactNode } from "react";
-
-interface FieldProps {
-  label: string;
-  children: ReactNode;
-  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */
-  className?: string;
-}
-
-/**
- * 폼 라벨 + 값 배치. `<label>`이 children을 함께 감싸 암시적 라벨 연결(implicit label)을
- * 제공한다 — 조회 팝업에서 readOnly `Input`을 감싸는 용도.
- */
-export function Field({ label, children, className }: FieldProps) {
-  return (
-    <label className={`flex flex-col gap-1 ${className ?? ""}`}>
-      <span className="text-label-md text-foreground-muted">{label}</span>
-      {children}
-    </label>
-  );
-}
 
components/ui/header.tsx (deleted)
--- components/ui/header.tsx
@@ -1,34 +0,0 @@
-import Image from "next/image";
-import type { ReactNode } from "react";
-
-interface HeaderProps {
-  title?: string;
-  leading?: ReactNode;
-  /** 우측 슬롯 */
-  children?: ReactNode;
-  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값(색상·타이포·간격 등) 주입 금지. */
-  className?: string;
-}
-
-/** 상단 헤더 셸 — 로고 + 타이틀 + 우측 슬롯. */
-export function Header({ title, leading, children, className }: HeaderProps) {
-  return (
-    <header
-      className={`flex items-center justify-between gap-4 border-b border-border bg-surface px-6 py-4 ${className ?? ""}`}
-    >
-      <div className="flex items-center gap-3">
-        {leading}
-        <Image
-          src="/images/logo-foxpay.svg"
-          alt="FoxPay"
-          width={170}
-          height={62}
-          className="h-8 w-auto"
-          priority
-        />
-        {title && <span className="text-title-md text-foreground">{title}</span>}
-      </div>
-      {children && <div className="flex items-center gap-3">{children}</div>}
-    </header>
-  );
-}
 
components/ui/icon-button.tsx (deleted)
--- components/ui/icon-button.tsx
@@ -1,48 +0,0 @@
-import type { ReactNode } from "react";
-
-interface IconButtonProps {
-  /** 접근성 이름(`aria-label`)과 네이티브 툴팁(`title`)으로 쓰인다. 생략하면 두
-   * 속성을 렌더하지 않는다 — 이 경우 children에 포함된 `sr-only` 텍스트가 접근성
-   * 이름이 되어야 한다(예: 아이콘 여러 개를 CSS로 전환 노출하며 각각 옆에 개별
-   * `sr-only` 라벨을 두는 구성). */
-  label?: string;
-  /** 아이콘(들). 장식용 아이콘은 자체적으로 `aria-hidden`을 갖는다 — 접근성 이름이
-   * 필요하면 `label`을 전달하거나 children에 `sr-only` 텍스트를 포함해야 한다. */
-  children: ReactNode;
-  type?: "button" | "submit";
-  /** 2-상태 토글일 때만 전달 → `aria-pressed` 렌더. 3-상태 순환 등에서는 생략 권장
-   * (`aria-pressed`는 이진 개념이라 의미가 어긋난다). */
-  isPressed?: boolean;
-  onClick?: () => void;
-  disabled?: boolean;
-  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */
-  className?: string;
-}
-
-/**
- * 아이콘 전용 버튼 비주얼. 상태를 가지지 않는 프레젠테이션 컴포넌트 — 클릭 동작·상태
- * 관리는 호출부(feature) 책임이다.
- */
-export function IconButton({
-  label,
-  children,
-  type = "button",
-  isPressed,
-  onClick,
-  disabled,
-  className,
-}: IconButtonProps) {
-  return (
-    <button
-      type={type}
-      onClick={onClick}
-      disabled={disabled}
-      aria-label={label}
-      title={label}
-      aria-pressed={isPressed}
-      className={`inline-flex size-9 items-center justify-center rounded-md border border-border text-foreground-muted transition-colors duration-200 hover:bg-surface-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${className ?? ""}`}
-    >
-      {children}
-    </button>
-  );
-}
 
components/ui/input.tsx (deleted)
--- components/ui/input.tsx
@@ -1,16 +0,0 @@
-import type { ComponentProps } from "react";
-
-type InputProps = ComponentProps<"input">;
-
-/** 기본 텍스트 입력. `readOnly`일 때 배경 톤 + 기본 커서로 편집 불가 상태를 시각 구분한다. */
-export function Input({ className, readOnly, ...props }: InputProps) {
-  return (
-    <input
-      readOnly={readOnly}
-      className={`h-10 w-full rounded-md border border-border px-3 text-body-md text-foreground placeholder:text-foreground-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${
-        readOnly ? "cursor-default bg-surface-muted" : "bg-surface"
-      } ${className ?? ""}`}
-      {...props}
-    />
-  );
-}
 
components/ui/modal.tsx (deleted)
--- components/ui/modal.tsx
@@ -1,62 +0,0 @@
-"use client";
-
-import { useEffect, useId, type ReactNode } from "react";
-import { IconButton } from "@/components/ui/icon-button";
-
-interface ModalProps {
-  title: string;
-  children: ReactNode;
-  footer?: ReactNode;
-  /** scrim 클릭·ESC·닫기 버튼에서 호출된다. 실제로 마운트 해제할지는 호출부(feature) 책임. */
-  onClose: () => void;
-}
-
-/**
- * 조회/입력 팝업 셸(scrim + 패널). 열림 여부는 호출부가 마운트/언마운트로 제어한다.
- * alert.tsx(확인 다이얼로그)와 달리 폼 필드를 담는 용도라 ESC 키·scrim 클릭·닫기 버튼에서
- * onClose를 호출하는 상호작용을 자체적으로 가진다 — 그래서 이 컴포넌트만 'use client'.
- * 포커스 트랩은 다루지 않는다(호출부 책임).
- */
-export function Modal({ title, children, footer, onClose }: ModalProps) {
-  const titleId = useId();
-
-  useEffect(() => {
-    function handleKeyDown(event: KeyboardEvent) {
-      if (event.key === "Escape") {
-        onClose();
-      }
-    }
-    document.addEventListener("keydown", handleKeyDown);
-    return () => {
-      document.removeEventListener("keydown", handleKeyDown);
-    };
-  }, [onClose]);
-
-  return (
-    <div
-      className="fixed inset-0 z-50 flex items-center justify-center bg-scrim p-4"
-      onClick={onClose}
-    >
-      <div
-        role="dialog"
-        aria-modal="true"
-        aria-labelledby={titleId}
-        onClick={(event) => event.stopPropagation()}
-        className="w-full max-w-lg animate-dialog-in rounded-xl bg-surface p-6 shadow-dialog"
-      >
-        <div className="flex items-center justify-between gap-4">
-          <h2 id={titleId} className="text-title-lg text-foreground">
-            {title}
-          </h2>
-          <IconButton label="닫기" onClick={onClose}>
-            <span aria-hidden="true" className="text-title-md leading-none">
-              ×
-            </span>
-          </IconButton>
-        </div>
-        <div className="mt-4">{children}</div>
-        {footer && <div className="mt-6 flex justify-end gap-2">{footer}</div>}
-      </div>
-    </div>
-  );
-}
 
components/ui/page-error.module.scss (added)
+++ components/ui/page-error.module.scss
@@ -0,0 +1,35 @@
+@use "@fox/styles/abstracts" as fox;
+
+// 콘텐츠가 들어올 자리를 그대로 차지하고 가운데에 안내를 놓는다 — PageLoading과 같은 규칙이라
+// 로딩에서 오류로 바뀔 때 레이아웃이 흔들리지 않는다.
+.root {
+  display: flex;
+  flex: 1 0 0;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: fox.gap(3);
+  min-block-size: 24rem;
+  padding: fox.padding(9);
+}
+
+.title {
+  margin: 0;
+  color: fox.color(font-neutral-strong);
+  font-family: fox.font-family(title);
+  font-size: fox.font-size(heading-sm);
+  font-weight: fox.font-weight(bold);
+  line-height: 1.5;
+}
+
+.description {
+  margin: 0;
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-md);
+  line-height: 1.5;
+}
+
+.action {
+  margin-block-start: fox.gap(3);
+}
 
components/ui/page-error.tsx (added)
+++ components/ui/page-error.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { FoxButton } from '@fox/core/components/fox-button';
+import styles from './page-error.module.scss';
+
+interface PageErrorProps {
+  onRetry: () => void;
+}
+
+/**
+ * 라우트 세그먼트 오류 폴백. 화면 다섯 곳의 `error.tsx`가 같은 모양이라 여기 한 번만 둔다
+ * (PageLoading과 같은 이유).
+ *
+ * 원본 메시지·스택은 여기서 받지 않는다 — 진단용 기록은 호출부(`error.tsx`)가 콘솔에만 남기고,
+ * 화면에는 일반화된 문구와 재시도만 낸다.
+ */
+export function PageError({ onRetry }: PageErrorProps) {
+  return (
+    <section className={styles.root}>
+      <h1 className={styles.title}>문제가 발생했습니다.</h1>
+      <p className={styles.description}>잠시 후 다시 시도해 주세요.</p>
+      <FoxButton
+        type="primary"
+        size="md"
+        label="다시 시도"
+        onAction={onRetry}
+        className={styles.action}
+      />
+    </section>
+  );
+}
 
components/ui/pagination.tsx (deleted)
--- components/ui/pagination.tsx
@@ -1,109 +0,0 @@
-import Link from "next/link";
-import type { ReactNode } from "react";
-
-interface PaginationProps {
-  /** 1-based. */
-  currentPage: number;
-  totalPages: number;
-  /** 각 페이지 번호로 이동할 href를 만들어 준다 — 라우팅은 소비처 책임. */
-  buildHref: (page: number) => string;
-  /** 한 번에 보여줄 번호 개수. 기본 5. */
-  windowSize?: number;
-}
-
-const ITEM_CLASSES =
-  "inline-flex size-9 items-center justify-center rounded-md text-body-sm transition-colors duration-200";
-const ENABLED_CLASSES = "text-foreground-muted hover:bg-surface-muted hover:text-foreground";
-const DISABLED_CLASSES = "text-foreground-subtle opacity-50";
-const CURRENT_CLASSES = "bg-primary text-on-primary";
-
-/** 현재 페이지를 중심으로 windowSize개 번호를 [1, totalPages] 범위 안에서 슬라이딩한다. */
-function getPageWindow(currentPage: number, totalPages: number, windowSize: number): number[] {
-  if (totalPages < 1) {
-    return [];
-  }
-  const half = Math.floor(windowSize / 2);
-  let start = Math.max(1, currentPage - half);
-  const end = Math.min(totalPages, start + windowSize - 1);
-  start = Math.max(1, end - windowSize + 1);
-
-  const pages: number[] = [];
-  for (let page = start; page <= end; page += 1) {
-    pages.push(page);
-  }
-  return pages;
-}
-
-interface PaginationItemProps {
-  disabled: boolean;
-  href: string;
-  label: string;
-  children: ReactNode;
-}
-
-/** 처음·이전·다음·끝 이동 항목 — 비활성일 때는 링크 대신 aria-disabled span으로 렌더한다. */
-function PaginationItem({ disabled, href, label, children }: PaginationItemProps) {
-  if (disabled) {
-    return (
-      <span aria-disabled="true" aria-label={label} className={`${ITEM_CLASSES} ${DISABLED_CLASSES}`}>
-        {children}
-      </span>
-    );
-  }
-  return (
-    <Link href={href} aria-label={label} className={`${ITEM_CLASSES} ${ENABLED_CLASSES}`}>
-      {children}
-    </Link>
-  );
-}
-
-/**
- * 페이지네이션 — « ‹ [번호들] › ». 페이지 이동은 `next/link` href 기반이며 실제 라우팅(쿼리
- * 구성 등)은 `buildHref`를 통해 호출부(feature)가 책임진다. 첫/마지막 페이지에서는 처음·이전·
- * 다음·끝 이동이 비활성 처리된다.
- */
-export function Pagination({
-  currentPage,
-  totalPages,
-  buildHref,
-  windowSize = 5,
-}: PaginationProps) {
-  const pages = getPageWindow(currentPage, totalPages, windowSize);
-  const isFirst = currentPage <= 1;
-  const isLast = currentPage >= totalPages;
-
-  return (
-    <nav aria-label="페이지네이션" className="flex items-center justify-center gap-1">
-      <PaginationItem disabled={isFirst} href={buildHref(1)} label="처음 페이지">
-        «
-      </PaginationItem>
-      <PaginationItem
-        disabled={isFirst}
-        href={buildHref(Math.max(1, currentPage - 1))}
-        label="이전 페이지"
-      >
-        ‹
-      </PaginationItem>
-      {pages.map((page) => (
-        <Link
-          key={page}
-          href={buildHref(page)}
-          aria-current={page === currentPage ? "page" : undefined}
-          className={`${ITEM_CLASSES} ${page === currentPage ? CURRENT_CLASSES : ENABLED_CLASSES}`}
-        >
-          {page}
-        </Link>
-      ))}
-      <PaginationItem
-        disabled={isLast}
-        href={buildHref(Math.min(totalPages, currentPage + 1))}
-        label="다음 페이지"
-      >
-        ›
-      </PaginationItem>
-      <PaginationItem disabled={isLast} href={buildHref(totalPages)} label="마지막 페이지">
-        »
-      </PaginationItem>
-    </nav>
-  );
-}
 
components/ui/radio-group.tsx (deleted)
--- components/ui/radio-group.tsx
@@ -1,40 +0,0 @@
-interface RadioGroupOption {
-  value: string;
-  label: string;
-}
-
-interface RadioGroupProps {
-  name: string;
-  options: RadioGroupOption[];
-  defaultValue?: string;
-  disabled?: boolean;
-}
-
-/**
- * 네이티브 라디오 그룹(비제어) — 폼 제출 시 `name` 필드로 선택값이 함께 전달된다.
- * React 상태를 가지지 않으므로 `'use client'`가 필요 없다.
- */
-export function RadioGroup({ name, options, defaultValue, disabled = false }: RadioGroupProps) {
-  return (
-    <div role="radiogroup" className="flex items-center gap-4">
-      {options.map((option) => (
-        <label
-          key={option.value}
-          className={`flex items-center gap-2 text-body-md ${
-            disabled ? "text-foreground-subtle opacity-50" : "text-foreground"
-          }`}
-        >
-          <input
-            type="radio"
-            name={name}
-            value={option.value}
-            defaultChecked={option.value === defaultValue}
-            disabled={disabled}
-            className="size-4 accent-primary"
-          />
-          {option.label}
-        </label>
-      ))}
-    </div>
-  );
-}
 
components/ui/select.tsx (deleted)
--- components/ui/select.tsx
@@ -1,13 +0,0 @@
-import type { ComponentProps } from "react";
-
-type SelectProps = ComponentProps<"select">;
-
-/** 네이티브 `<select>` 래퍼(라이브러리 미사용) — 브라우저 기본 표시(화살표 등)를 그대로 쓴다. */
-export function Select({ className, ...props }: SelectProps) {
-  return (
-    <select
-      className={`h-10 rounded-md border border-border bg-surface px-3 text-body-md text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${className ?? ""}`}
-      {...props}
-    />
-  );
-}
 
components/ui/sidebar-group.tsx (deleted)
--- components/ui/sidebar-group.tsx
@@ -1,76 +0,0 @@
-import type { ReactNode } from "react";
-
-interface SidebarGroupProps {
-  label: string;
-  icon?: ReactNode;
-  /** 펼침 상태 — 상태 소유는 소비처(feature)다. 이 컴포넌트는 비제어 프레젠테이션이다. */
-  isExpanded: boolean;
-  onToggle: () => void;
-  /** 자식 SidebarLink들(isNested로 들여쓰기). */
-  children: ReactNode;
-}
-
-/**
- * 사이드바 그룹 헤딩 — 자식 내비게이션 항목(SidebarLink)을 펼침/접힘으로 묶는다.
- * 상태를 갖지 않는 프레젠테이션 컴포넌트로, IconButton·SidebarLink와 동일한 관례로
- * 'use client'를 붙이지 않는다: onToggle을 받아 <button onClick>에 연결하지만,
- * 이 파일 자체는 훅을 쓰지 않는 순수 함수 컴포넌트다. Next.js App Router의 클라이언트
- * 경계는 "이 모듈을 어느 파일이 import하는가"로 정해지므로, 실제 펼침 상태(useState 등)를
- * 들고 있는 소비처(feature) 파일이 'use client'면 이 컴포넌트도 그 클라이언트 모듈
- * 그래프에 포함되어 정상 동작한다(node_modules/next/dist/docs/01-app/01-getting-started/
- * 05-server-and-client-components.md "module graph" 설명 참조).
- *
- * aria-controls는 의도적으로 생략했다 — 대상 id를 안전하게 만들 방법이 마땅치 않다
- * (useId는 위 이유로 이 컴포넌트에서 쓸 수 없고, label을 id로 슬러그화하면 공백 포함
- * 라벨이나 중복 라벨에서 깨질 수 있다). WAI-ARIA APG의 Disclosure 패턴에서 aria-controls는
- * 필수가 아니라 선택 사항이며(스크린리더 지원도 일관적이지 않음), aria-expanded + 버튼
- * 바로 다음에 내용이 오는 DOM 인접 구조만으로 충분하다고 판단했다.
- */
-export function SidebarGroup({ label, icon, isExpanded, onToggle, children }: SidebarGroupProps) {
-  return (
-    <div>
-      <button
-        type="button"
-        onClick={onToggle}
-        aria-expanded={isExpanded}
-        className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-label-lg text-foreground transition-colors duration-200 hover:bg-surface-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
-      >
-        {icon}
-        {/* min-w-0: SidebarLink와 동일한 truncate 대응(flex 아이템 기본 min-width:auto가
-            overflow-hidden을 무력화하는 문제). 접힘 폭(md:w-16)에서도 한 줄 유지 + 말줄임만
-            적용한다 — 아이콘 전용 표시로 전환하는 정식 해법은 아이콘 자산이 없어 이번
-            범위에서 다루지 않는다(SidebarLink와 동일 사유). */}
-        <span className="min-w-0 flex-1 truncate">{label}</span>
-        <ChevronIcon
-          className={`shrink-0 transition-transform duration-200 ${isExpanded ? "rotate-90" : "rotate-0"}`}
-        />
-      </button>
-      {isExpanded && <div className="flex flex-col">{children}</div>}
-    </div>
-  );
-}
-
-/**
- * 펼침/접힘 표시용 셰브론(오른쪽 화살표 — 펼치면 90도 회전해 아래를 향한다). 그룹 헤딩
- * 전용 장식 요소라 이 파일 안에 둔다.
- *
- * ⚠️ 손으로 그린 마지막 아이콘이다. 디자인시스템에 같은 그림(`FoxCaretRightIcon`)이 있지만,
- * 회전(`rotate-90`)과 크기(`size-4`)를 Tailwind 클래스에 기대고 있어 그 두 개를 실재하는
- * CSS로 옮기기 전에는 바꿔도 나아지지 않는다 — components/ui 전체 리스타일과 함께 처리한다.
- */
-function ChevronIcon({ className }: { className?: string }) {
-  return (
-    <svg
-      viewBox="0 0 24 24"
-      fill="none"
-      stroke="currentColor"
-      strokeWidth="2"
-      strokeLinecap="round"
-      strokeLinejoin="round"
-      aria-hidden="true"
-      className={`size-4 ${className ?? ""}`}
-    >
-      <path d="M9 6l6 6-6 6" />
-    </svg>
-  );
-}
 
components/ui/sidebar.tsx (deleted)
--- components/ui/sidebar.tsx
@@ -1,27 +0,0 @@
-import type { ReactNode } from "react";
-
-interface SidebarProps {
-  header?: ReactNode;
-  children: ReactNode;
-  footer?: ReactNode;
-  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */
-  className?: string;
-}
-
-/** 사이드바 셸(컨테이너). 내비게이션 항목 구성·활성 상태 판단은 호출부(feature) 책임. */
-export function Sidebar({ header, children, footer, className }: SidebarProps) {
-  return (
-    <aside
-      // h-full(퍼센트 높이)을 쓰지 않는다 — 부모가 flex-grow로 산출한 높이는 CSS 퍼센트
-      // 해석의 기준(containing block)이 되지 못해 h-full이 auto로 무너질 수 있다. 대신
-      // 명시적 높이를 지정하지 않아(auto) 부모 flex 컨테이너의 기본 align-items:stretch가
-      // 그대로 적용되게 한다 — 이 방식은 부모 높이가 퍼센트든 flex-grow든 항상 안정적으로
-      // 동작한다.
-      className={`flex w-64 flex-col border-r border-border bg-surface ${className ?? ""}`}
-    >
-      {header && <div className="border-b border-border p-4">{header}</div>}
-      <nav className="flex-1 overflow-y-auto p-2">{children}</nav>
-      {footer && <div className="border-t border-border p-4">{footer}</div>}
-    </aside>
-  );
-}
 
components/ui/table.tsx (deleted)
--- components/ui/table.tsx
@@ -1,61 +0,0 @@
-import type { ReactNode } from "react";
-
-interface TableProps {
-  children: ReactNode;
-  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */
-  className?: string;
-}
-
-/** 테이블 컨테이너 — 가로 스크롤 래퍼(`overflow-x-auto`) + `<table>`. */
-export function Table({ children, className }: TableProps) {
-  return (
-    <div className={`w-full overflow-x-auto ${className ?? ""}`}>
-      <table className="w-full border-collapse text-body-sm text-foreground">{children}</table>
-    </div>
-  );
-}
-
-interface TableSectionProps {
-  children: ReactNode;
-}
-
-/** 테이블 헤더 영역(`<thead>`). */
-export function TableHead({ children }: TableSectionProps) {
-  return <thead className="bg-surface-muted">{children}</thead>;
-}
-
-/** 테이블 본문 영역(`<tbody>`). */
-export function TableBody({ children }: TableSectionProps) {
-  return <tbody>{children}</tbody>;
-}
-
-interface TableRowProps {
-  children: ReactNode;
-  className?: string;
-}
-
-/** 테이블 행(`<tr>`) — 아래쪽 구분선을 기본으로 갖는다. */
-export function TableRow({ children, className }: TableRowProps) {
-  return <tr className={`border-b border-border ${className ?? ""}`}>{children}</tr>;
-}
-
-interface TableCellProps {
-  children: ReactNode;
-  className?: string;
-}
-
-/** 헤더 셀(`<th>`). */
-export function TableHeaderCell({ children, className }: TableCellProps) {
-  return (
-    <th
-      className={`px-3 py-2 text-left text-label-md text-foreground-muted ${className ?? ""}`}
-    >
-      {children}
-    </th>
-  );
-}
-
-/** 본문 셀(`<td>`). */
-export function TableCell({ children, className }: TableCellProps) {
-  return <td className={`px-3 py-2 text-body-sm ${className ?? ""}`}>{children}</td>;
-}
Add a comment
List