임동욱 임동욱 08-19
merge: hub 최신 반영
@58d425a32a54fce22b30dbdc24c9ff8e2f584d0b
 
@fox/core/components/fox-alert/fox-alert.tsx (added)
+++ @fox/core/components/fox-alert/fox-alert.tsx
@@ -0,0 +1,132 @@
+import type { ReactNode, Ref } from "react";
+import {
+  FoxChatDotsIcon,
+  FoxCheckCircleIcon,
+  FoxInfoIcon,
+  FoxProhibitIcon,
+  FoxWarningIcon,
+} from "../../icons";
+import { cx } from "../../utils";
+
+export type FoxAlertType =
+  | "default"
+  | "information"
+  | "success"
+  | "warning"
+  | "danger";
+
+export interface FoxAlertProps {
+  /**
+   * 알림의 성격. 아이콘·배경·테두리·제목 색이 여기서 갈린다.
+   *
+   * 계열이 바꾸는 것은 배경 · 테두리 · 제목 글자색과 아이콘이다. 본문 글자색은 계열과
+   * 무관하게 같다.
+   *
+   * 아이콘은 계열이 정하고 굵기는 시안대로 `duotone`이며, 색은 제목 글자와 같은 계열색이다.
+   */
+  type?: FoxAlertType;
+  /** 굵은 제목 줄. 넘기지 않으면 영역을 렌더링하지 않는다. */
+  title?: ReactNode;
+  /** 제목 아래 본문. 넘기지 않으면 영역을 렌더링하지 않는다. */
+  message?: ReactNode;
+  /**
+   * 보조기술이 읽는 방식을 정한다(`FoxToast`와 같은 규칙).
+   *
+   * - `false`(기본): `role="status"` — 하던 말을 끊지 않는다. 화면과 함께 처음부터 놓여
+   *   있는 알림은 이쪽이다.
+   * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 사용자의 동작 결과로 **그 자리에
+   *   나타난** 알림에만 쓴다.
+   *
+   * `type="danger"`가 자동으로 이걸 켜지 않는다 — 위험한 내용인 것과 지금 끼어들어야 하는
+   * 것은 다른 문제이고, 페이지와 함께 그려지는 경고까지 낭독을 끊으면 방해만 된다.
+   */
+  urgent?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 시안의 폭(720px)을 바꿔야 할 때도 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const TYPE_CLASS: Record<FoxAlertType, string> = {
+  default: "fox-alert--default",
+  information: "fox-alert--information",
+  success: "fox-alert--success",
+  warning: "fox-alert--warning",
+  danger: "fox-alert--danger",
+};
+
+/**
+ * 계열이 정하는 아이콘. 호출부가 넘기지 않는다 — 무엇을 그릴지는 계열이 이미 답한다
+ * (`FoxTag`와 같은 규칙).
+ *
+ * 시안 `ico/*`가 Phosphor 원본이라 이름이 그대로 붙는다(`ico/Prohibit` →
+ * `FoxProhibitIcon`). 손으로 SVG를 그려 넣지 않는다 — 아이콘은 생성물이 단일 출처다.
+ *
+ * 굵기는 시안이 `Weight=Duotone`이라 다섯 개 모두 `duotone`이다. 기본값(`regular`)으로 두면
+ * 속이 빈 선 그림이 되어 시안과 달라지므로 반드시 적는다.
+ *
+ * duotone은 연한 면(`opacity` 0.2)과 진한 선을 겹쳐 그리는데 **둘 다 `currentColor`다** —
+ * 색은 스타일이 `.fox-alert__icon`에 준 하나를 쓰고, 연한 쪽만 알아서 흐려진다.
+ */
+const TYPE_ICON: Record<FoxAlertType, ReactNode> = {
+  default: <FoxChatDotsIcon weight="duotone" />,
+  information: <FoxInfoIcon weight="duotone" />,
+  success: <FoxCheckCircleIcon weight="duotone" />,
+  warning: <FoxWarningIcon weight="duotone" />,
+  danger: <FoxProhibitIcon weight="duotone" />,
+};
+
+/**
+ * @fox 알럿. 화면 안에 놓이는 띠 모양 알림이다 — 아이콘 상자 · 제목 · 본문이 한 줄로 선다.
+ *
+ * **모달이 아니다.** 화면을 덮지도, 포커스를 가두지도, 확인 버튼을 갖지도 않는다. 내용 흐름
+ * 안에 그대로 놓이고, 언제 나타나고 사라질지는 호출부가 정한다(`FoxToast`와 같은 방식).
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 시안 주석: **본문은 두 줄 이내**로 쓴다. 길이를 코드가 막지는 않는다 — 세 줄이 되면
+ * 아이콘 상자만 가운데 남고 띠가 늘어난다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxAlert({
+  type = "default",
+  title,
+  message,
+  urgent = false,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxAlertProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      role={urgent ? "alert" : "status"}
+      className={cx("fox-alert", TYPE_CLASS[type], className)}
+    >
+      {/* 아이콘이 정해지기 전에도 상자는 그린다 — 시안이 흰 판을 배치의 일부로 준다. */}
+      <span className="fox-alert__icon" aria-hidden="true">
+        {TYPE_ICON[type]}
+      </span>
+
+      <div className="fox-alert__content">
+        {title === undefined ? null : (
+          <strong className="fox-alert__title">{title}</strong>
+        )}
+        {message === undefined ? null : (
+          <span className="fox-alert__message">{message}</span>
+        )}
+      </div>
+    </div>
+  );
+}
 
@fox/core/components/fox-alert/index.ts (added)
+++ @fox/core/components/fox-alert/index.ts
@@ -0,0 +1,1 @@
+export { FoxAlert, type FoxAlertProps, type FoxAlertType } from "./fox-alert";
 
@fox/core/components/fox-contextual-trigger/fox-contextual-trigger.tsx (added)
+++ @fox/core/components/fox-contextual-trigger/fox-contextual-trigger.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import type { ComponentPropsWithRef, ReactNode } from "react";
+import { FoxInfoIcon, FoxQuestionIcon } from "../../icons";
+import { cx } from "../../utils";
+
+/** 무엇을 여는 단추인지. 아이콘과 기본 이름이 여기서 갈린다. */
+export type FoxContextualTriggerType = "help" | "information";
+
+/**
+ * 네이티브 `<button>`이 받는 것을 그대로 넘겨받는다 — onFocus·onBlur·onKeyDown·form 등.
+ * 툴팁을 띄우는 자리에서 `onFocus`·`onBlur`가 실제로 필요하다(마우스만 보면 키보드
+ * 사용자가 툴팁을 볼 수 없다). 의미가 겹치는 것만 걷어내고 아래에서 다시 정의한다.
+ */
+type NativeButtonProps = Omit<
+  ComponentPropsWithRef<"button">,
+  "type" | "className" | "children" | "hidden" | "onClick"
+>;
+
+export interface FoxContextualTriggerProps extends NativeButtonProps {
+  /** 무엇을 여는 단추인지. 네이티브 `type`은 언제나 `"button"`으로 고정된다. */
+  type?: FoxContextualTriggerType;
+  /**
+   * 읽어 줄 이름. 넘기지 않으면 계열이 정한 기본값을 쓴다(`help`는 "도움말",
+   * `information`은 "설명").
+   *
+   * 아이콘만 있는 단추라 이 글자가 **유일한 이름이다.** 한 화면에 여러 개가 있으면
+   * "배송비 도움말"처럼 무엇에 대한 것인지 적어 준다 — 같은 이름이 여럿이면 소리로는
+   * 구분되지 않는다.
+   */
+  ariaLabel?: string;
+  /**
+   * 이 단추가 설명하는 내용의 id. **툴팁을 여는 자리에 쓴다** — 트리거가
+   * `aria-describedby`로 툴팁을 가리켜야 보조기술이 그 글을 읽어 준다(`FoxTooltip`·
+   * `FoxTooltipRich`는 스스로 아무 역할도 갖지 않는다).
+   */
+  describedBy?: string;
+  /**
+   * 눌러서 여는 판이 있을 때 그 판의 id. **팝오버를 여는 자리에 쓴다.**
+   * `expanded`와 함께 넘긴다.
+   */
+  controls?: string;
+  /** 눌러서 여는 판이 지금 열려 있는지(`aria-expanded`). 팝오버를 여는 자리에서만 쓴다. */
+  expanded?: boolean;
+  disabled?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 눌렀을 때. 팝오버처럼 눌러서 여는 판에 쓴다. */
+  onAction?: () => void;
+  /**
+   * 마우스가 올라오고 벗어날 때. **툴팁을 띄우고 지우는 자리에 쓴다** — 이 단추는 상태를
+   * 갖지 않으므로 무엇을 보일지는 호출부가 정한다.
+   *
+   * ⚠️ 마우스만 보면 키보드 사용자가 툴팁을 볼 수 없다. 호출부가 `onFocus`·`onBlur`도 함께
+   * 다뤄야 한다 — 네이티브 속성이라 그대로 넘기면 된다.
+   */
+  onHoverChange?: (hovered: boolean) => void;
+  /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */
+  className?: string;
+}
+
+/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const TYPE_CLASS: Record<FoxContextualTriggerType, string> = {
+  help: "fox-contextual-trigger--help",
+  information: "fox-contextual-trigger--information",
+};
+
+/** 계열이 정하는 아이콘. 시안 `ico/Question`·`ico/Info`이고 굵기는 둘 다 regular다. */
+const TYPE_ICON: Record<FoxContextualTriggerType, ReactNode> = {
+  help: <FoxQuestionIcon />,
+  information: <FoxInfoIcon />,
+};
+
+/** 이름을 안 주면 쓰는 기본값. 무엇을 여는 단추인지까지만 말한다. */
+const TYPE_LABEL: Record<FoxContextualTriggerType, string> = {
+  help: "도움말",
+  information: "설명",
+};
+
+/**
+ * @fox 도움말·설명 단추. 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추다.
+ *
+ * 상태를 갖지 않는다 — 무엇을 언제 보여 줄지는 호출부가 정하고, 이 단추는 그 배선을 걸 자리
+ * (`describedBy`·`controls`·`expanded`)와 손이 닿는 자리를 준다.
+ *
+ * `FoxIconButton`과 다른 조각이다. 저쪽은 폼 컨트롤과 높이를 맞추는 단추라 크기가
+ * `form(height-*)`을 따르는데(가장 작은 xsm도 32px), 이쪽은 글줄 옆에 붙는 24px 고정이고
+ * 모서리도 `radius-2`(4px)다.
+ *
+ * 배경은 세 상태 모두 `action-secondary` 계열이다 — 기본이 투명이라 아래 배경이 그대로
+ * 비치고, hover·pressed에서만 연한 판이 깔린다(`FoxTag`와 같은 규칙).
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxContextualTrigger({
+  type = "help",
+  ariaLabel,
+  describedBy,
+  controls,
+  expanded,
+  disabled = false,
+  hidden = false,
+  onAction,
+  onHoverChange,
+  className,
+  ...rest
+}: FoxContextualTriggerProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <button
+      {...rest}
+      type="button"
+      disabled={disabled}
+      aria-label={ariaLabel ?? TYPE_LABEL[type]}
+      aria-describedby={describedBy}
+      aria-controls={controls}
+      aria-expanded={expanded}
+      className={cx("fox-contextual-trigger", TYPE_CLASS[type], className)}
+      onClick={onAction}
+      onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
+      onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
+    >
+      <span className="fox-contextual-trigger__icon" aria-hidden="true">
+        {TYPE_ICON[type]}
+      </span>
+    </button>
+  );
+}
 
@fox/core/components/fox-contextual-trigger/index.ts (added)
+++ @fox/core/components/fox-contextual-trigger/index.ts
@@ -0,0 +1,5 @@
+export {
+  FoxContextualTrigger,
+  type FoxContextualTriggerProps,
+  type FoxContextualTriggerType,
+} from "./fox-contextual-trigger";
@fox/core/components/fox-file-upload/fox-file-upload.tsx
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
@@ -9,7 +9,7 @@
   type CSSProperties,
   type DragEvent,
 } from "react";
-import { FoxFormLabel } from "../fox-form-label";
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxButton } from "../fox-button";
 import { FoxIconButton } from "../fox-icon-button";
@@ -47,6 +47,11 @@
   accept?: string;
   multiple?: boolean;
   /**
+   * 네이티브 파일 입력의 `name`. 주면 고른 파일이 감싼 `<form>`의 제출에 그대로 실린다 —
+   * 값을 따로 들고 있지 않아도 서버로 넘어간다.
+   */
+  name?: string;
+  /**
    * `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다.
    * 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다.
    */
@@ -55,6 +60,8 @@
   droppable?: boolean;
 
   label?: string;
+  /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */
+  requirement?: FoxFormLabelRequirement;
   description?: string;
   placeholder?: string;
   areaText?: string;
@@ -188,9 +195,11 @@
   onRemove,
   accept,
   multiple = false,
+  name,
   showList,
   droppable = false,
   label,
+  requirement,
   description,
   placeholder = "파일을 선택해 주세요.",
   areaText = DEFAULT_AREA_TEXT,
@@ -318,7 +327,7 @@
       onDrop={handleDrop}
     >
       {label && (
-        <FoxFormLabel as="span" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`} requirement={requirement}>
           {label}
         </FoxFormLabel>
       )}
@@ -327,6 +336,7 @@
       <input
         ref={pickerRef}
         type="file"
+        name={name}
         accept={accept}
         multiple={multiple}
         disabled={disabled}
 
@fox/core/components/fox-heading-group/fox-heading-group.tsx (added)
+++ @fox/core/components/fox-heading-group/fox-heading-group.tsx
@@ -0,0 +1,64 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxHeadingGroupProps {
+  /** 구역 제목. 화면 제목(`FoxPageHeader`)이 아니라 그 아래 구역의 이름이다. */
+  title: ReactNode;
+  /**
+   * 제목을 그릴 태그. 한 화면에 여러 구역이 서면 문서 구조가 어긋나지 않게 단계를 고른다.
+   * 화면 제목은 `FoxPageHeader`가 `<h1>`으로 그리므로 여기 기본은 `h2`다.
+   */
+  as?: "h2" | "h3" | "h4";
+  /** 제목 아래 한 줄 설명. 넘기지 않으면 영역을 그리지 않는다. */
+  description?: ReactNode;
+  /** 제목 오른쪽 자리. 보통 버튼이다. */
+  actions?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 구역 머리말 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177)
+ *
+ * 한 화면 안에서 목록·표 같은 구역을 이름 짓는다. `FoxPageHeader`와 역할이 다르다 — 그쪽은
+ * 화면에 하나뿐인 제목(`<h1>`)과 현재 위치를 갖고, 이쪽은 화면 안에 여럿 설 수 있다.
+ * 그래서 제목 태그를 `as`로 고를 수 있고 breadcrumb이 없다.
+ *
+ * 아래 여백은 이 조각이 갖는다(시안 spacing/bottom/md) — 뒤따르는 도구 줄·표가 간격을 따로
+ * 두지 않아도 되게 하려는 것이고, `FoxPageHeader`와 같은 규칙이다.
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxHeadingGroup({
+  title,
+  as: Tag = "h2",
+  description,
+  actions,
+  hidden = false,
+  className,
+  ref,
+}: FoxHeadingGroupProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div ref={ref} className={cx("fox-heading-group", className)}>
+      <div className="fox-heading-group__text">
+        <div className="fox-heading-group__heading">
+          <Tag className="fox-heading-group__title">{title}</Tag>
+        </div>
+        {description && (
+          <p className="fox-heading-group__description">{description}</p>
+        )}
+      </div>
+      {actions && <div className="fox-heading-group__actions">{actions}</div>}
+    </div>
+  );
+}
 
@fox/core/components/fox-heading-group/index.ts (added)
+++ @fox/core/components/fox-heading-group/index.ts
@@ -0,0 +1,1 @@
+export { FoxHeadingGroup, type FoxHeadingGroupProps } from "./fox-heading-group";
 
@fox/core/components/fox-helper-text/fox-helper-text.tsx (added)
+++ @fox/core/components/fox-helper-text/fox-helper-text.tsx
@@ -0,0 +1,105 @@
+import type { ReactNode, Ref } from "react";
+import {
+  FoxChatDotsIcon,
+  FoxCheckCircleIcon,
+  FoxInfoIcon,
+  FoxProhibitIcon,
+  FoxWarningIcon,
+} from "../../icons";
+import { cx } from "../../utils";
+
+/** `FoxAlert`와 같은 다섯 계열이다 — 시안이 두 컴포넌트에 같은 축을 쓴다. */
+export type FoxHelperTextType =
+  | "default"
+  | "information"
+  | "success"
+  | "warning"
+  | "danger";
+
+export interface FoxHelperTextProps {
+  /** 글자색과 아이콘이 여기서 갈린다. */
+  type?: FoxHelperTextType;
+  /** 보여 줄 문구. */
+  message: ReactNode;
+  /**
+   * 아이콘을 그릴지. 시안은 다섯 계열 모두 아이콘을 달지만, 글자만 필요한 자리가 있어
+   * 끌 수 있게 둔다 — 폼 컴포넌트의 헬퍼가 지금 아이콘 없이 쓰이는 자리가 많다
+   * (`FoxInput.messageIcon`이 선택값인 것과 같은 사정).
+   */
+  showIcon?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /**
+   * 입력의 `aria-describedby`가 가리킬 값. 폼 옆에 놓을 때 넘겨서 이어 준다 —
+   * 이어 주지 않으면 화면에만 보이고 소리로는 전달되지 않는다.
+   */
+  id?: string;
+  /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */
+  className?: string;
+  ref?: Ref<HTMLParagraphElement>;
+}
+
+/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const TYPE_CLASS: Record<FoxHelperTextType, string> = {
+  default: "fox-helper-text--default",
+  information: "fox-helper-text--information",
+  success: "fox-helper-text--success",
+  warning: "fox-helper-text--warning",
+  danger: "fox-helper-text--danger",
+};
+
+/**
+ * 계열이 정하는 아이콘. 글리프는 `FoxAlert`와 같은 것을 쓰고 **굵기만 다르다** —
+ * 알럿은 `duotone`, 여기는 `regular`다(시안 확인).
+ */
+const TYPE_ICON: Record<FoxHelperTextType, ReactNode> = {
+  default: <FoxChatDotsIcon />,
+  information: <FoxInfoIcon />,
+  success: <FoxCheckCircleIcon />,
+  warning: <FoxWarningIcon />,
+  danger: <FoxProhibitIcon />,
+};
+
+/**
+ * @fox 헬퍼 텍스트. 입력 아래 한 줄로 붙는 안내·오류 문구다.
+ *
+ * 상태를 갖지 않는다 — 어떤 계열로 보일지는 호출부가 정한다.
+ *
+ * TODO(폼 통합, 적용 후 이 문단 삭제): 폼 컴포넌트 여섯 곳(`FoxInput`·`FoxTextArea`·
+ * `FoxEmail`·`FoxPhoneNumber`·`FoxAddress`의 `__message`, `FoxSelect`의 `__hint`)이
+ * 아직 각자 그린다. 이걸로 모으는 절차와 주의점은 `@fox/styles/_fox-helper-text.scss`
+ * 상단에 적어 두었다 — `FoxSelect`만 간격과 오류 아이콘 색이 달라지므로 확인이 필요하다.
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxHelperText({
+  type = "default",
+  message,
+  showIcon = true,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxHelperTextProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <p
+      ref={ref}
+      id={id}
+      className={cx("fox-helper-text", TYPE_CLASS[type], className)}
+    >
+      {showIcon ? (
+        <span className="fox-helper-text__icon" aria-hidden="true">
+          {TYPE_ICON[type]}
+        </span>
+      ) : null}
+      <span className="fox-helper-text__message">{message}</span>
+    </p>
+  );
+}
 
@fox/core/components/fox-helper-text/index.ts (added)
+++ @fox/core/components/fox-helper-text/index.ts
@@ -0,0 +1,5 @@
+export {
+  FoxHelperText,
+  type FoxHelperTextProps,
+  type FoxHelperTextType,
+} from "./fox-helper-text";
@fox/core/components/fox-input/fox-input.tsx
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
@@ -48,6 +48,8 @@
   messageIcon?: ReactNode;
   /** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */
   icon?: ReactNode;
+  /** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */
+  suffix?: ReactNode;
   /** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */
   clearable?: boolean;
   clearLabel?: string;
@@ -108,6 +110,7 @@
   message,
   messageIcon,
   icon,
+  suffix,
   clearable = false,
   clearLabel = "입력 지우기",
   invalid = false,
@@ -253,6 +256,8 @@
           </button>
         )}
 
+        {suffix && <span className="fox-input__suffix">{suffix}</span>}
+
         {icon && (
           <span className="fox-input__icon" aria-hidden="true">
             {icon}
@fox/core/components/fox-list-container/fox-list-container.tsx
--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
@@ -42,6 +42,11 @@
   title?: ReactNode;
   breadcrumb?: FoxBreadcrumbItem[];
   headerActions?: ReactNode;
+  /**
+   * 머리말과 목록 사이. 시안에서 이 자리에 오는 것은 `FoxTab`이다 — 목록 전체를 갈아 끼우는
+   * 전환이라 도구 줄(정렬·검색)보다 위에 선다.
+   */
+  tabs?: ReactNode;
 
   // ── 표 ────────────────────────────────────────────────────────────────────
   columns: FoxListColumn<T>[];
@@ -107,6 +112,7 @@
   title,
   breadcrumb,
   headerActions,
+  tabs,
   columns,
   rows,
   rowKey,
@@ -170,6 +176,8 @@
         />
       )}
 
+      {tabs}
+
       <section className="fox-list-container__section">
         {hasToolbar && (
           <FoxListToolbar
 
@fox/core/components/fox-popover/fox-popover.tsx (added)
+++ @fox/core/components/fox-popover/fox-popover.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import type { ReactNode, Ref } from "react";
+import { FoxCaretRightIcon, FoxXIcon } from "../../icons";
+import { cx } from "../../utils";
+import { FoxTextButton } from "../fox-text-button";
+
+/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */
+export type FoxPopoverDirection = "top" | "bottom";
+
+/** 꼬리가 판의 가로 어디에 붙는지. */
+export type FoxPopoverArrow = "left" | "center" | "right";
+
+export interface FoxPopoverProps {
+  /**
+   * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다.
+   * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다.
+   */
+  direction?: FoxPopoverDirection;
+  /** 꼬리의 가로 위치. 판 가장자리에서 24px 안쪽에 선다(가운데는 예외). */
+  arrow?: FoxPopoverArrow;
+  /** 굵은 제목 줄. 넘기지 않으면 렌더링하지 않는다. */
+  title?: ReactNode;
+  /** 제목 아래 본문. 넘기지 않으면 렌더링하지 않는다. */
+  message?: ReactNode;
+  /** 아래쪽 글자 버튼의 문구(예: "바로가기"). 없으면 버튼을 그리지 않는다. */
+  actionLabel?: string;
+  /** 글자 버튼을 눌렀을 때. `actionLabel`이 없으면 의미 없다. */
+  onAction?: () => void;
+  /** 닫기(X)를 눌렀을 때. **필수다** — 시안이 닫기를 항상 그린다. */
+  onClose: () => void;
+  /** 닫기 버튼을 읽어 줄 이름. X 아이콘은 장식이라 이 글자가 유일한 이름이다. */
+  closeLabel?: string;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 트리거 옆에 놓는 위치와 시안의 폭(360px)을 여기로 덮는다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+const DIRECTION_CLASS: Record<FoxPopoverDirection, string> = {
+  top: "fox-popover--top",
+  bottom: "fox-popover--bottom",
+};
+
+const ARROW_CLASS: Record<FoxPopoverArrow, string> = {
+  left: "fox-popover--arrow-left",
+  center: "fox-popover--arrow-center",
+  right: "fox-popover--arrow-right",
+};
+
+/**
+ * @fox 팝오버. 꼬리가 달린 말풍선 판이다. 제목·본문·글자 버튼을 담고 오른쪽 위에 닫기가 붙는다.
+ *
+ * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 언제 열리고 닫힐지는 호출부가 정하고,
+ * 트리거 옆 어디에 놓을지는 `className`으로 준다 — `direction`·`arrow`는 꼬리가 어디에
+ * 붙는지만 정한다(자리가 모자랄 때 위아래를 뒤집는 자동 배치는 없다. 시안이 방향을 값으로
+ * 주므로 그 판단은 호출부의 몫이다).
+ *
+ * ⚠️ 판을 띄우는 방식은 `FoxSelect`·`FoxChip`의 목록과 같다 — 감싼 요소 기준의 일반 흐름이라
+ * `overflow: hidden` 조상 안에서는 잘린다. HTML `popover` 속성으로 top layer에 올리는 방법이
+ * 있지만, 위치를 잡아 주는 CSS Anchor Positioning이 아직 크롬 계열에만 있어 미룬다 — 옮기게
+ * 되면 셀렉트·칩·툴팁까지 한 번에 옮긴다.
+ *
+ * 꼬리는 **CSS가 그린다** — 이 컴포넌트가 내보내는 것은 빈 요소 하나뿐이다. 시안이 준 SVG는
+ * 1px 흰 띠로 판의 1px 테두리를 지우는 구조라 소수 배율 화면에서 이음새에 실선이 비쳤고,
+ * 회전한 정사각형을 판에 절반 걸치는 방식으로 바꿨다(까닭은 스타일 파일 상단 참고).
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxPopover({
+  direction = "top",
+  arrow = "left",
+  title,
+  message,
+  actionLabel,
+  onAction,
+  onClose,
+  closeLabel = "닫기",
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxPopoverProps) {
+  if (hidden) {
+    return null;
+  }
+
+  // 꼬리는 CSS가 그린다 — 이 요소는 자리만 잡는다(스타일 파일 참고).
+  const arrowSlot = (
+    <div className="fox-popover__arrow-area" aria-hidden="true">
+      <span className="fox-popover__arrow" />
+    </div>
+  );
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      className={cx(
+        "fox-popover",
+        DIRECTION_CLASS[direction],
+        ARROW_CLASS[arrow],
+        className
+      )}
+    >
+      {direction === "top" ? arrowSlot : null}
+
+      <div className="fox-popover__content">
+        {title === undefined && message === undefined ? null : (
+          <div className="fox-popover__body">
+            {title === undefined ? null : (
+              <div className="fox-popover__title-row">
+                <strong className="fox-popover__title">{title}</strong>
+              </div>
+            )}
+            {message === undefined ? null : (
+              <div className="fox-popover__message-row">
+                <span className="fox-popover__message">{message}</span>
+              </div>
+            )}
+          </div>
+        )}
+
+        {actionLabel ? (
+          <FoxTextButton
+            type="secondary"
+            size="sm"
+            label={actionLabel}
+            trailingIcon={<FoxCaretRightIcon />}
+            onAction={onAction}
+            className="fox-popover__action"
+          />
+        ) : null}
+
+        <button
+          type="button"
+          className="fox-popover__close"
+          aria-label={closeLabel}
+          onClick={onClose}
+        >
+          <FoxXIcon />
+        </button>
+      </div>
+
+      {direction === "bottom" ? arrowSlot : null}
+    </div>
+  );
+}
 
@fox/core/components/fox-popover/index.ts (added)
+++ @fox/core/components/fox-popover/index.ts
@@ -0,0 +1,6 @@
+export {
+  FoxPopover,
+  type FoxPopoverProps,
+  type FoxPopoverDirection,
+  type FoxPopoverArrow,
+} from "./fox-popover";
 
@fox/core/components/fox-snackbar-list/fox-snackbar-list.tsx (added)
+++ @fox/core/components/fox-snackbar-list/fox-snackbar-list.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxSnackbarListProps {
+  /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */
+  label?: string;
+  /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */
+  labelledBy?: string;
+  /** `FoxSnackbar`들. */
+  children?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. 띄우는 자리(fixed·bottom 등)와 시안의 폭(640px)을 여기로 덮는다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 스낵바 묶음. 스낵바가 여러 개일 때 세로로 쌓는다. 배치만 책임지고 상태를 갖지 않는다 —
+ * 무엇이 몇 개 떠 있는지, 어떤 순서로 쌓을지는 호출부가 소유한다.
+ *
+ * 자식에게 덮어쓰는 값이 없다(`FoxTagList`·`FoxChipArea`와 다른 점이다) — 스낵바에는 계열도
+ * 크기도 없어서 묶음이 통일할 것이 폭과 간격뿐이다.
+ *
+ * 방향 수식어가 없다 — 스낵바는 세로로 쌓이는 것이 유일한 배치다.
+ *
+ * `role="group"`이다. 각 스낵바가 스스로 `role="status"`로 읽히므로 묶음은 자리만 만든다 —
+ * 묶음까지 live region으로 만들면 하나가 뜰 때마다 목록 전체가 다시 읽힌다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxSnackbarList({
+  label,
+  labelledBy,
+  children,
+  hidden = false,
+  className,
+  ref,
+}: FoxSnackbarListProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      role="group"
+      aria-label={labelledBy ? undefined : label}
+      aria-labelledby={labelledBy}
+      className={cx("fox-snackbar-list", className)}
+    >
+      {children}
+    </div>
+  );
+}
 
@fox/core/components/fox-snackbar-list/index.ts (added)
+++ @fox/core/components/fox-snackbar-list/index.ts
@@ -0,0 +1,1 @@
+export { FoxSnackbarList, type FoxSnackbarListProps } from "./fox-snackbar-list";
 
@fox/core/components/fox-snackbar/fox-snackbar.tsx (added)
+++ @fox/core/components/fox-snackbar/fox-snackbar.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import type { ReactNode, Ref } from "react";
+import { FoxXIcon } from "../../icons";
+import { cx } from "../../utils";
+import { FoxTextButton } from "../fox-text-button";
+
+export interface FoxSnackbarProps {
+  /**
+   * 글자 앞에 붙는 아이콘. 넘기지 않으면 자리를 만들지 않는다.
+   *
+   * 계열이 없는 조각이라 무엇을 그릴지는 호출부가 정한다(`FoxAlert`처럼 계열이 아이콘을
+   * 고르지 않는다). `@fox/core/icons`에서 가져다 넘긴다 — 시안은 `fill` 굵기다:
+   * `<FoxCheckCircleIcon weight="fill" />`.
+   *
+   * 색은 스타일이 정한다(`icon-theme-primary`) — 글리프가 `fill: currentColor`라 넘기는
+   * 쪽에서 색을 줄 필요가 없다.
+   */
+  icon?: ReactNode;
+  /** 굵은 첫 줄. 넘기지 않으면 영역을 렌더링하지 않는다 — 본문만 있는 스낵바가 된다. */
+  title?: ReactNode;
+  /** 제목 아래 본문. 넘기지 않으면 영역을 렌더링하지 않는다. */
+  message?: ReactNode;
+  /**
+   * 오른쪽 글자 버튼의 문구. 없으면 버튼을 그리지 않는다.
+   *
+   * 버튼은 `FoxTextButton`의 `primary`·`md`로 고정이다(시안 지정) — 계열·크기를 열지 않는
+   * 것은 스낵바마다 버튼 모양이 달라지면 같은 조각으로 읽히지 않기 때문이다.
+   */
+  actionLabel?: string;
+  /** 글자 버튼을 눌렀을 때. `actionLabel`이 없으면 의미 없다. */
+  onAction?: () => void;
+  /**
+   * 닫기(X)를 눌렀을 때. **필수다** — 시안이 닫기를 항상 그리므로, 눌러도 아무 일이 없는
+   * 버튼이 남지 않게 호출부가 반드시 처리를 준다.
+   */
+  onClose: () => void;
+  /** 닫기 버튼을 읽어 줄 이름. X 아이콘은 장식이라 이 글자가 유일한 이름이다. */
+  closeLabel?: string;
+  /**
+   * 보조기술이 읽는 방식을 정한다(`FoxToast`·`FoxAlert`와 같은 규칙).
+   *
+   * - `false`(기본): `role="status"` — 하던 말을 끊지 않는다.
+   * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 지금 손을 멈춰야 하는 알림에만.
+   */
+  urgent?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 띄우는 자리(fixed·bottom 등)와 시안의 폭(640px)을 여기로 덮는다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 스낵바. 흰 판 위에 굵은 한 줄과 글자 버튼이 서고, 오른쪽 끝에 닫기가 붙는다.
+ * 크기 수식어가 없다 — 시안에 하나뿐이다.
+ *
+ * `FoxToast`와 형제지만 **누를 것이 있다.** 토스트는 읽고 지나가는 조각이라 스스로 사라지고,
+ * 스낵바는 사용자가 되돌리거나(실행 취소) 이어서 할 일이 있어 손이 닿을 때까지 남는다.
+ *
+ * **상태를 갖지 않는다.** 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정한다
+ * — `onClose`는 "닫아 달라"는 알림이지 이 조각이 스스로 사라지는 것이 아니다.
+ *
+ * ⚠️ 보조기술은 **이미 화면에 있던 live region의 내용이 바뀔 때** 읽는다. 이 요소를 통째로
+ * 새로 붙이면 조합에 따라 읽히지 않을 수 있다(`FoxToast`와 같은 사정).
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxSnackbar({
+  icon,
+  title,
+  message,
+  actionLabel,
+  onAction,
+  onClose,
+  closeLabel = "닫기",
+  urgent = false,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxSnackbarProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      role={urgent ? "alert" : "status"}
+      className={cx("fox-snackbar", className)}
+    >
+      {icon ? (
+        <span className="fox-snackbar__icon" aria-hidden="true">
+          {icon}
+        </span>
+      ) : null}
+
+      <div className="fox-snackbar__content">
+        {title === undefined ? null : (
+          <strong className="fox-snackbar__title">{title}</strong>
+        )}
+        {message === undefined ? null : (
+          <span className="fox-snackbar__message">{message}</span>
+        )}
+      </div>
+
+      {actionLabel ? (
+        <FoxTextButton
+          type="primary"
+          size="md"
+          label={actionLabel}
+          onAction={onAction}
+          className="fox-snackbar__action"
+        />
+      ) : null}
+
+      <button
+        type="button"
+        className="fox-snackbar__close"
+        aria-label={closeLabel}
+        onClick={onClose}
+      >
+        <FoxXIcon />
+      </button>
+    </div>
+  );
+}
 
@fox/core/components/fox-snackbar/index.ts (added)
+++ @fox/core/components/fox-snackbar/index.ts
@@ -0,0 +1,1 @@
+export { FoxSnackbar, type FoxSnackbarProps } from "./fox-snackbar";
 
@fox/core/components/fox-status-indicator/fox-status-indicator.tsx (added)
+++ @fox/core/components/fox-status-indicator/fox-status-indicator.tsx
@@ -0,0 +1,71 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+/** `FoxAlert`·`FoxHelperText`와 같은 다섯 계열이다 — 시안이 같은 축을 쓴다. */
+export type FoxStatusIndicatorType =
+  | "default"
+  | "information"
+  | "success"
+  | "warning"
+  | "danger";
+
+export interface FoxStatusIndicatorProps {
+  /** 점과 글자의 색이 여기서 갈린다. */
+  type?: FoxStatusIndicatorType;
+  /** 상태를 나타내는 글자. */
+  label: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */
+  className?: string;
+  ref?: Ref<HTMLSpanElement>;
+}
+
+/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const TYPE_CLASS: Record<FoxStatusIndicatorType, string> = {
+  default: "fox-status-indicator--default",
+  information: "fox-status-indicator--information",
+  success: "fox-status-indicator--success",
+  warning: "fox-status-indicator--warning",
+  danger: "fox-status-indicator--danger",
+};
+
+/**
+ * @fox 상태 표시. 작은 점 하나와 글자로 상태를 알린다. 크기 수식어가 없다 — 시안에 하나뿐이다.
+ *
+ * **점은 장식이다**(`aria-hidden`). 상태의 의미는 글자가 전부 담는다 — 색만으로 뜻을 전하면
+ * 색을 구분하지 못하는 사용자에게는 아무것도 전달되지 않기 때문이고, 그래서 `label`이
+ * 선택값이 아니라 필수값이다.
+ *
+ * 값이 바뀌는 것을 소리로 알려야 하는 자리(예: 처리 중 → 완료)라면 감싼 쪽이 live region을
+ * 만든다 — 이 조각은 상태를 갖지 않으므로 스스로 알리지 않는다.
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxStatusIndicator({
+  type = "default",
+  label,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxStatusIndicatorProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <span
+      ref={ref}
+      id={id}
+      className={cx("fox-status-indicator", TYPE_CLASS[type], className)}
+    >
+      <span className="fox-status-indicator__dot" aria-hidden="true" />
+      <span className="fox-status-indicator__label">{label}</span>
+    </span>
+  );
+}
 
@fox/core/components/fox-status-indicator/index.ts (added)
+++ @fox/core/components/fox-status-indicator/index.ts
@@ -0,0 +1,5 @@
+export {
+  FoxStatusIndicator,
+  type FoxStatusIndicatorProps,
+  type FoxStatusIndicatorType,
+} from "./fox-status-indicator";
 
@fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx (added)
+++ @fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx
@@ -0,0 +1,108 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+/** 시안의 state. 지난 단계 · 지금 단계 · 아직 오지 않은 단계다. */
+export type FoxStepIndicatorItemState = "completion" | "ongoing" | "before";
+
+export interface FoxStepIndicatorItemProps {
+  state?: FoxStepIndicatorItemState;
+  /** 단계 번호 줄(작고 흐린 글자). 넘기지 않으면 렌더링하지 않는다. */
+  step?: ReactNode;
+  /** 단계 제목 줄. 넘기지 않으면 렌더링하지 않는다. */
+  title?: ReactNode;
+  /**
+   * 동그라미 오른쪽으로 뻗는 선. **마지막 단계에서는 꺼야 한다** — 켜 두면 선이 묶음 밖으로
+   * 삐져나간다. 선 색은 `state`가 정한다(지난 단계만 진하다).
+   */
+  line?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 시안의 폭(80px)을 바꿔야 할 때도 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/** `Record`로 고정해 상태를 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const STATE_CLASS: Record<FoxStepIndicatorItemState, string> = {
+  completion: "fox-step-indicator-item--completion",
+  ongoing: "fox-step-indicator-item--ongoing",
+  before: "fox-step-indicator-item--before",
+};
+
+/**
+ * @fox 단계 하나. 위에 동그라미와 선, 아래에 단계 번호와 제목이 놓인다.
+ * `FoxStepIndicator` 안에 여러 개를 늘어놓아 쓴다.
+ *
+ * 상태를 갖지 않는다 — 어디까지 왔는지는 호출부가 각 단계의 `state`로 정한다.
+ *
+ * **모바일에서는 글자가 사라지고 동그라미와 선만 남는다.** 그 판단은 CSS가 한다(화면 폭) —
+ * 기기를 prop으로 받지 않는다. 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고,
+ * React를 쓰지 않는 소비자는 같은 결과를 얻지 못한다(`FoxChipSelectOption`의 확인 버튼과
+ * 같은 근거).
+ *
+ * ⚠️ 진행 상태를 소리로도 전해야 하면 감싼 쪽이 알린다 — 이 조각의 동그라미·선은 장식이라
+ * 보조기술에 아무것도 말하지 않고, 읽히는 것은 단계 번호와 제목뿐이다. 모바일에서는 그
+ * 글자마저 사라지므로, 그 화면에서 순서를 전해야 하면 호출부가 이름을 따로 준다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxStepIndicatorItem({
+  state = "before",
+  step,
+  title,
+  line = true,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxStepIndicatorItemProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      className={cx("fox-step-indicator-item", STATE_CLASS[state], className)}
+    >
+      <div className="fox-step-indicator-item__indicator" aria-hidden="true">
+        <span className="fox-step-indicator-item__mark">
+          {state === "completion" ? <CheckMark /> : null}
+          {state === "ongoing" ? <span className="fox-step-indicator-item__dot" /> : null}
+        </span>
+        {line ? <span className="fox-step-indicator-item__line" /> : null}
+      </div>
+
+      {step === undefined && title === undefined ? null : (
+        <div className="fox-step-indicator-item__text">
+          {step === undefined ? null : (
+            <span className="fox-step-indicator-item__step">{step}</span>
+          )}
+          {title === undefined ? null : (
+            <span className="fox-step-indicator-item__title">{title}</span>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 지난 단계의 체크 표시. `@fox/core/icons`(Phosphor)를 쓰지 않는다 — 시안이 준 것은 선으로
+// 그린 10×8 글리프이고, Phosphor의 Check는 면으로 채운 다른 그림이라 크기·굵기가 맞지 않는다.
+// `stroke`를 시안의 리터럴(흰색) 대신 `currentColor`로 두면 색을 상태 규칙이 정한다.
+function CheckMark() {
+  return (
+    <svg viewBox="0 0 12 10" fill="none" aria-hidden="true">
+      <path
+        d="M0.75 4.75006L4.96726 8.75006L10.75 0.750061"
+        stroke="currentColor"
+        strokeWidth="1.5"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </svg>
+  );
+}
 
@fox/core/components/fox-step-indicator-item/index.ts (added)
+++ @fox/core/components/fox-step-indicator-item/index.ts
@@ -0,0 +1,1 @@
+export { FoxStepIndicatorItem, type FoxStepIndicatorItemProps, type FoxStepIndicatorItemState } from "./fox-step-indicator-item";
 
@fox/core/components/fox-step-indicator/fox-step-indicator.tsx (added)
+++ @fox/core/components/fox-step-indicator/fox-step-indicator.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxStepIndicatorProps {
+  /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */
+  label?: string;
+  /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */
+  labelledBy?: string;
+  /** `FoxStepIndicatorItem`들. */
+  children?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. 시안의 폭(400px = 80px × 5단계)을 바꿔야 할 때도 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 단계 묶음. `FoxStepIndicatorItem`들을 가로로 늘어놓는다. 배치만 책임지고 상태를 갖지 않는다 —
+ * 어디까지 왔는지는 각 단계의 `state`가 말한다.
+ *
+ * 자식에게 덮어쓰는 값이 없다(`FoxTagList`·`FoxChipArea`와 다른 점이다). 단계마다 `state`가
+ * 다른 것이 정상이고, **마지막 단계는 `line={false}`로 선을 꺼야 한다** — 묶음이 자식의
+ * 순서를 알고 대신 꺼 주지 않는 것은, 자식을 조건부로 렌더하거나 프래그먼트로 감싸는
+ * 호출부에서 "마지막"의 판단이 어긋나기 때문이다.
+ *
+ * `role="list"`를 쓰지 않는다. 목록이 아니라 하나의 진행 상황이고, 읽히는 것은 각 단계의
+ * 글자다 — 진행률을 소리로 전해야 하면 호출부가 `label`에 담는다("전체 5단계 중 2단계" 등).
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxStepIndicator({
+  label,
+  labelledBy,
+  children,
+  hidden = false,
+  className,
+  ref,
+}: FoxStepIndicatorProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      role="group"
+      aria-label={labelledBy ? undefined : label}
+      aria-labelledby={labelledBy}
+      className={cx("fox-step-indicator", className)}
+    >
+      {children}
+    </div>
+  );
+}
 
@fox/core/components/fox-step-indicator/index.ts (added)
+++ @fox/core/components/fox-step-indicator/index.ts
@@ -0,0 +1,4 @@
+export {
+  FoxStepIndicator,
+  type FoxStepIndicatorProps,
+} from "./fox-step-indicator";
 
@fox/core/components/fox-toast/fox-toast.tsx (added)
+++ @fox/core/components/fox-toast/fox-toast.tsx
@@ -0,0 +1,67 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxToastProps {
+  /**
+   * 알림 문구.
+   *
+   * 이름이 `label`이 아니다 — 다른 컴포넌트의 `label`은 컨트롤에 붙는 이름이지만, 이쪽은
+   * 조각 안에 담기는 **내용**이라 성격이 다르다.
+   */
+  message: ReactNode;
+  /**
+   * 보조기술이 읽는 방식을 정한다.
+   *
+   * - `false`(기본): `role="status"` — 하던 말을 끊지 않고 차례가 오면 읽는다. 저장 완료처럼
+   *   놓쳐도 되는 알림.
+   * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 지금 손을 멈춰야 하는 알림에만
+   *   쓴다. 남발하면 화면 낭독이 계속 끊긴다.
+   */
+  urgent?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 띄우는 위치(fixed·bottom 등)를 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 토스트. 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다.
+ *
+ * **상태를 갖지 않는다.** 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정한다
+ * (사용자 결정) — 이 조각은 조건부로 렌더하고 `className`으로 자리를 잡아 주면 된다. 타이머와
+ * 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈린다.
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 보조기술은 **이미 화면에 있던 live region의 내용이 바뀔 때** 읽는다. 이 요소를 통째로 새로
+ * 붙이면 브라우저·리더 조합에 따라 읽히지 않을 수 있다. 확실히 읽혀야 하는 자리라면 호출부가
+ * 빈 `<div role="status">`를 미리 두고 그 안에서 토스트를 갈아 끼운다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxToast({
+  message,
+  urgent = false,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxToastProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      role={urgent ? "alert" : "status"}
+      className={cx("fox-toast", className)}
+    >
+      {message}
+    </div>
+  );
+}
 
@fox/core/components/fox-toast/index.ts (added)
+++ @fox/core/components/fox-toast/index.ts
@@ -0,0 +1,1 @@
+export { FoxToast, type FoxToastProps } from "./fox-toast";
 
@fox/core/components/fox-tooltip-rich/fox-tooltip-rich.tsx (added)
+++ @fox/core/components/fox-tooltip-rich/fox-tooltip-rich.tsx
@@ -0,0 +1,118 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */
+export type FoxTooltipRichDirection = "top" | "bottom";
+
+/** 꼬리가 판의 가로 어디에 붙는지. */
+export type FoxTooltipRichArrow = "left" | "center" | "right";
+
+export interface FoxTooltipRichProps {
+  /**
+   * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다.
+   * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다.
+   */
+  direction?: FoxTooltipRichDirection;
+  /** 꼬리의 가로 위치. 판 가장자리에서 24px 안쪽에 선다(가운데는 예외). */
+  arrow?: FoxTooltipRichArrow;
+  /** 굵은 제목 줄. 넘기지 않으면 렌더링하지 않는다. */
+  title?: ReactNode;
+  /**
+   * 본문. **150자 내외의 글자만 담는다**(시안 주석) — 버튼·링크 같은 누를 것을 넣지 않는다.
+   * 누를 것이 필요하면 툴팁이 아니라 `FoxPopover`다.
+   */
+  message?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 트리거 옆에 놓는 위치와 시안의 폭(360px)을 여기로 덮는다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+const DIRECTION_CLASS: Record<FoxTooltipRichDirection, string> = {
+  top: "fox-tooltip-rich--top",
+  bottom: "fox-tooltip-rich--bottom",
+};
+
+const ARROW_CLASS: Record<FoxTooltipRichArrow, string> = {
+  left: "fox-tooltip-rich--arrow-left",
+  center: "fox-tooltip-rich--arrow-center",
+  right: "fox-tooltip-rich--arrow-right",
+};
+
+/**
+ * @fox 리치 툴팁. 제목과 본문을 담은 말풍선이다.
+ *
+ * `FoxPopover`와 판·꼬리의 구조가 같지만 **누를 것이 하나도 없다** — 닫기도 액션 버튼도
+ * 없고, 시안 주석대로 150자 내외의 글자만 담는다. 그래서 여닫는 것은 전적으로 트리거의
+ * 몫이다(가리키거나 포커스가 닿는 동안 보이는 식). 사용자가 눌러야 할 것이 생기면 그때는
+ * 툴팁이 아니라 팝오버다 — 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없다.
+ *
+ * 테두리도 그 차이를 따른다: 팝오버는 진한 선(`border-neutral-strong`)이고 이쪽은 한 단계
+ * 옅은 선(`border-neutral-default`)이다.
+ *
+ * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 트리거 옆 어디에 놓을지는 `className`으로
+ * 주고, `direction`·`arrow`는 꼬리가 어디에 붙는지만 정한다.
+ *
+ * ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않는다. 툴팁으로 읽히려면 트리거가
+ * `aria-describedby`로 이 요소의 `id`를 가리켜야 한다 — 그 배선은 트리거 쪽이 맡는다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxTooltipRich({
+  direction = "top",
+  arrow = "left",
+  title,
+  message,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxTooltipRichProps) {
+  if (hidden) {
+    return null;
+  }
+
+  // 꼬리는 CSS가 그린다 — 이 요소는 자리만 잡는다(스타일 파일 참고).
+  const arrowSlot = (
+    <div className="fox-tooltip-rich__arrow-area" aria-hidden="true">
+      <span className="fox-tooltip-rich__arrow" />
+    </div>
+  );
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      className={cx(
+        "fox-tooltip-rich",
+        DIRECTION_CLASS[direction],
+        ARROW_CLASS[arrow],
+        className
+      )}
+    >
+      {direction === "top" ? arrowSlot : null}
+
+      <div className="fox-tooltip-rich__content">
+        {title === undefined && message === undefined ? null : (
+          <div className="fox-tooltip-rich__body">
+            {title === undefined ? null : (
+              <div className="fox-tooltip-rich__title-row">
+                <strong className="fox-tooltip-rich__title">{title}</strong>
+              </div>
+            )}
+            {message === undefined ? null : (
+              <div className="fox-tooltip-rich__message-row">
+                <span className="fox-tooltip-rich__message">{message}</span>
+              </div>
+            )}
+          </div>
+        )}
+      </div>
+
+      {direction === "bottom" ? arrowSlot : null}
+    </div>
+  );
+}
 
@fox/core/components/fox-tooltip-rich/index.ts (added)
+++ @fox/core/components/fox-tooltip-rich/index.ts
@@ -0,0 +1,6 @@
+export {
+  FoxTooltipRich,
+  type FoxTooltipRichProps,
+  type FoxTooltipRichDirection,
+  type FoxTooltipRichArrow,
+} from "./fox-tooltip-rich";
 
@fox/core/components/fox-tooltip/fox-tooltip.tsx (added)
+++ @fox/core/components/fox-tooltip/fox-tooltip.tsx
@@ -0,0 +1,96 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */
+export type FoxTooltipDirection = "top" | "bottom";
+
+export interface FoxTooltipProps {
+  /**
+   * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다.
+   * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다.
+   *
+   * 가로 위치를 고르는 값은 없다 — 시안이 꼬리를 언제나 가운데에 둔다(`FoxTooltipRich`와
+   * 다른 점이다).
+   */
+  direction?: FoxTooltipDirection;
+  /** 보여 줄 글자. 한 줄짜리 짧은 말이다 — 길어지면 `FoxTooltipRich`를 쓴다. */
+  message: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 트리거 옆에 놓는 위치를 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+const DIRECTION_CLASS: Record<FoxTooltipDirection, string> = {
+  top: "fox-tooltip--top",
+  bottom: "fox-tooltip--bottom",
+};
+
+/**
+ * @fox 툴팁. 어두운 판에 짧은 글 한 줄을 담은 말풍선이다.
+ *
+ * `FoxTooltipRich`의 작은 형제다 — 제목이 없고, 꼬리가 언제나 가운데이며, 판이 글자만큼만
+ * 넓어진다. 여기도 **누를 것이 하나도 없다**: 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없다.
+ *
+ * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 언제 보일지와 트리거 옆 어디에 놓을지는
+ * 호출부가 정하고, `direction`은 꼬리가 위아래 중 어디에 붙는지만 정한다.
+ *
+ * ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않는다. 툴팁으로 읽히려면 트리거가
+ * `aria-describedby`로 이 요소의 `id`를 가리켜야 한다 — 그 배선은 트리거 쪽이 맡는다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxTooltip({
+  direction = "top",
+  message,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxTooltipProps) {
+  if (hidden) {
+    return null;
+  }
+
+  const arrowSlot = (
+    <div className="fox-tooltip__arrow-area" aria-hidden="true">
+      <span className="fox-tooltip__arrow">
+        <ArrowMark />
+      </span>
+    </div>
+  );
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      className={cx("fox-tooltip", DIRECTION_CLASS[direction], className)}
+    >
+      {direction === "top" ? arrowSlot : null}
+      <div className="fox-tooltip__content">{message}</div>
+      {direction === "bottom" ? arrowSlot : null}
+    </div>
+  );
+}
+
+/**
+ * 시안 꼬리. 여기서는 `FoxPopover`·`FoxTooltipRich`처럼 CSS로 그리지 않고 SVG를 쓴다 —
+ * 저쪽은 테두리가 있어 1px 이음새를 맞춰야 했지만 이 판은 테두리 없이 한 색이라 그럴 일이
+ * 없고, 시안의 꼬리 **끝이 살짝 둥글어** CSS 삼각형으로는 그 모양이 나오지 않는다.
+ *
+ * 색은 리터럴 대신 `currentColor`라 스타일이 판과 같은 값을 준다. 마스크가 없어 문서에서
+ * id가 겹칠 일도 없다.
+ */
+function ArrowMark() {
+  return (
+    <svg viewBox="0 0 12 6" fill="none" aria-hidden="true">
+      <path
+        d="M12 0H0L4.93934 5.50991C5.52513 6.16336 6.47487 6.16336 7.06066 5.50991L12 0Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
 
@fox/core/components/fox-tooltip/index.ts (added)
+++ @fox/core/components/fox-tooltip/index.ts
@@ -0,0 +1,5 @@
+export {
+  FoxTooltip,
+  type FoxTooltipProps,
+  type FoxTooltipDirection,
+} from "./fox-tooltip";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -1,5 +1,6 @@
 export * from "./fox-accordion";
 export * from "./fox-address";
+export * from "./fox-alert";
 export * from "./fox-badge";
 export * from "./fox-badge-dot";
 export * from "./fox-badge-group";
@@ -21,10 +22,13 @@
 export * from "./fox-chip-area";
 export * from "./fox-chip-select-option";
 export * from "./fox-conditional-option";
+export * from "./fox-contextual-trigger";
 export * from "./fox-disclosure";
 export * from "./fox-email";
 export * from "./fox-file-upload";
 export * from "./fox-form-label";
+export * from "./fox-heading-group";
+export * from "./fox-helper-text";
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
@@ -32,6 +36,7 @@
 export * from "./fox-page-header";
 export * from "./fox-pagination";
 export * from "./fox-phone-number";
+export * from "./fox-popover";
 export * from "./fox-radio";
 export * from "./fox-radio-group";
 export * from "./fox-radio-icon";
@@ -41,7 +46,12 @@
 export * from "./fox-select-option";
 export * from "./fox-select-option-item";
 export * from "./fox-select-text";
+export * from "./fox-snackbar";
+export * from "./fox-snackbar-list";
 export * from "./fox-spinner";
+export * from "./fox-status-indicator";
+export * from "./fox-step-indicator-item";
+export * from "./fox-step-indicator";
 export * from "./fox-tab";
 export * from "./fox-tab-item";
 export * from "./fox-table";
@@ -51,6 +61,9 @@
 export * from "./fox-tag-list";
 export * from "./fox-text-area";
 export * from "./fox-text-button";
+export * from "./fox-toast";
+export * from "./fox-tooltip";
+export * from "./fox-tooltip-rich";
 export * from "./fox-toggle-quantity";
 export * from "./fox-toggle-switch";
 export * from "./fox-list-search";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -1,8 +1,9 @@
 "use client";
 
 import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components";
-import { useState, type CSSProperties, type ReactNode } from "react";
+import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
 import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
+import { FoxAlert } from "../core/components/fox-alert";
 import { FoxBreadcrumb } from "../core/components/fox-breadcrumb";
 import { FoxButton } from "../core/components/fox-button";
 import { FoxButtonGroup } from "../core/components/fox-button-group";
@@ -15,18 +16,22 @@
 import { FoxCheckbox } from "../core/components/fox-checkbox";
 import { FoxCheckboxGroup } from "../core/components/fox-checkbox-group";
 import { FoxConditionalOption } from "../core/components/fox-conditional-option";
+import { FoxContextualTrigger } from "../core/components/fox-contextual-trigger";
 import { FoxEmail } from "../core/components/fox-email";
 import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
 import { FoxFormLabel } from "../core/components/fox-form-label";
+import { FoxHelperText } from "../core/components/fox-helper-text";
 import { FoxIconButton } from "../core/components/fox-icon-button";
 import { FoxLinkButton } from "../core/components/fox-link-button";
 import { FoxPagination } from "../core/components/fox-pagination";
 import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
+import { FoxPopover } from "../core/components/fox-popover";
 import { FoxRadio } from "../core/components/fox-radio";
 import { FoxRadioGroup } from "../core/components/fox-radio-group";
 import { FoxRadioIcon } from "../core/components/fox-radio-icon";
 import { FoxTag } from "../core/components/fox-tag";
 import { FoxTagList } from "../core/components/fox-tag-list";
+import { FoxToast } from "../core/components/fox-toast";
 import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
 import { FoxTextButton } from "../core/components/fox-text-button";
 import { FoxToggleSwitch } from "../core/components/fox-toggle-switch";
@@ -50,11 +55,19 @@
 import { FoxTab } from "../core/components/fox-tab";
 import { FoxTabItem } from "../core/components/fox-tab-item";
 import { FoxSpinner } from "../core/components/fox-spinner";
+import { FoxCheckCircleIcon } from "../core/icons";
+import { FoxSnackbar } from "../core/components/fox-snackbar";
+import { FoxSnackbarList } from "../core/components/fox-snackbar-list";
+import { FoxStatusIndicator } from "../core/components/fox-status-indicator";
+import { FoxStepIndicatorItem } from "../core/components/fox-step-indicator-item";
+import { FoxStepIndicator } from "../core/components/fox-step-indicator";
 import { FoxChip, type FoxChipItem } from "../core/components/fox-chip";
 import { FoxChipArea } from "../core/components/fox-chip-area";
 import { FoxSelectText } from "../core/components/fox-select-text";
 import { FoxSegmentedControl } from "../core/components/fox-segmented-control";
 import { FoxSegmentedControlItem } from "../core/components/fox-segmented-control-item";
+import { FoxTooltip } from "../core/components/fox-tooltip";
+import { FoxTooltipRich } from "../core/components/fox-tooltip-rich";
 import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
 import { FoxPageHeader } from "../core/components/fox-page-header";
 import { FoxModal } from "../core/components/fox-modal";
@@ -601,7 +614,6 @@
   );
 }
 
-/** 모달은 열어 봐야 보이므로 여는 버튼과 함께 낸다. */
 function ModalDemo({
   size,
   title,
@@ -654,11 +666,6 @@
   );
 }
 
-/**
- * 그림 자리 확인용 도형. 실제 사진 대신 쓴다 — `@fox`는 앱의 이미지 경로를 참조하지 않고
- * (다른 프로젝트로 통째로 복사되어야 한다), 카탈로그가 외부 파일에 기대지도 않게 한다.
- * 토큰 색을 그대로 쓰므로 라이트·다크에서 함께 바뀐다.
- */
 function DemoThumb() {
   return (
     <svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
@@ -670,6 +677,151 @@
         opacity="0.45"
       />
     </svg>
+  );
+}
+
+function ToastDemo() {
+  const [open, setOpen] = useState(false);
+
+  useEffect(() => {
+    if (!open) return;
+    const timer = window.setTimeout(() => setOpen(false), 3000);
+    return () => window.clearTimeout(timer);
+  }, [open]);
+
+  return (
+    <>
+      <FoxButton
+        size="md"
+        type="primary"
+        label="토스트 띄우기"
+        onAction={() => setOpen(true)}
+        disabled={open}
+      />
+      {open ? (
+        <div
+          style={{
+            position: "fixed",
+            insetInlineStart: "50%",
+            insetBlockEnd: "4rem",
+            transform: "translateX(-50%)",
+            zIndex: 100,
+          }}
+        >
+          <FoxToast message="저장되었습니다." />
+        </div>
+      ) : null}
+    </>
+  );
+}
+
+/**
+ * 트리거와 말풍선을 잇는 예제. 트리거는 상태를 갖지 않으므로 무엇을 언제 보여 줄지와
+ * 보조기술 배선(`aria-describedby` / `aria-controls`+`aria-expanded`)은 호출부 몫이다.
+ */
+function ContextualTriggerDemo() {
+  const [tipOpen, setTipOpen] = useState(false);
+  const [popOpen, setPopOpen] = useState(false);
+  const [richOpen, setRichOpen] = useState(false);
+
+  return (
+    <>
+      {/* 툴팁: 가리키거나 포커스가 닿는 동안 보인다. */}
+      <span style={{ position: "relative", display: "inline-flex" }}>
+        <FoxContextualTrigger
+          type="help"
+          ariaLabel="배송비 도움말"
+          describedBy="demo-tooltip"
+          onHoverChange={setTipOpen}
+          onFocus={() => setTipOpen(true)}
+          onBlur={() => setTipOpen(false)}
+        />
+        {/* 꼬리가 가운데라 트리거 가운데에 맞춰 세운다. */}
+        {tipOpen ? (
+          <span
+            style={{
+              position: "absolute",
+              insetBlockStart: "100%",
+              insetInlineStart: "50%",
+              transform: "translateX(-50%)",
+              zIndex: 10,
+            }}
+          >
+          <FoxTooltip
+            id="demo-tooltip"
+            direction="top"
+            message="3만원 이상 무료"
+          />
+          </span>
+        ) : null}
+      </span>
+
+      {/* 팝오버: 눌러서 연다. */}
+      <span style={{ position: "relative", display: "inline-flex" }}>
+        <FoxContextualTrigger
+          type="information"
+          ariaLabel="정산 기준 설명"
+          controls="demo-popover"
+          expanded={popOpen}
+          onAction={() => setPopOpen((open) => !open)}
+        />
+        {/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
+        {popOpen ? (
+          <span
+            style={{
+              position: "absolute",
+              insetBlockStart: "100%",
+              insetInlineStart: "50%",
+              marginInlineStart: "-3.5rem",
+              zIndex: 10,
+            }}
+          >
+          <FoxPopover
+            id="demo-popover"
+            direction="top"
+            arrow="left"
+            title="정산 기준"
+            message="매월 말일 마감하여 다음 달 10일에 지급합니다."
+            actionLabel="바로가기"
+            onAction={() => {}}
+            onClose={() => setPopOpen(false)}
+          />
+          </span>
+        ) : null}
+      </span>
+
+      {/* 리치 툴팁: 툴팁과 같이 가리키는 동안 보이지만, 제목과 긴 본문을 담는다. */}
+      <span style={{ position: "relative", display: "inline-flex" }}>
+        <FoxContextualTrigger
+          type="information"
+          ariaLabel="정산 주기 안내"
+          describedBy="demo-tooltip-rich"
+          onHoverChange={setRichOpen}
+          onFocus={() => setRichOpen(true)}
+          onBlur={() => setRichOpen(false)}
+        />
+        {/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
+        {richOpen ? (
+          <span
+            style={{
+              position: "absolute",
+              insetBlockStart: "100%",
+              insetInlineStart: "50%",
+              marginInlineStart: "-3.5rem",
+              zIndex: 10,
+            }}
+          >
+            <FoxTooltipRich
+              id="demo-tooltip-rich"
+              direction="top"
+              arrow="left"
+              title="정산 주기"
+              message="매월 말일에 마감하고 다음 달 10일에 지급합니다. 공휴일이 겹치면 다음 영업일로 밀립니다."
+            />
+          </span>
+        ) : null}
+      </span>
+    </>
   );
 }
 
@@ -3555,4 +3707,482 @@
       },
     ],
   },
+  {
+    id: "fox-toast",
+    name: "FoxToast",
+    description:
+      "Figma 시안(toast 441:5471) 토스트메세지 — 상태를 갖지 않습니다: 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정합니다. 타이머와 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈립니다. 배경·글자색이 *-inverse 토큰이라 다크 모드에서는 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 됩니다.",
+    variants: [
+      {
+        label: "기본",
+        node: <FoxToast message="저장되었습니다." />,
+      },
+      {
+        label: "문구가 길면 폭이 내용만큼 늘어납니다 (시안에 상한이 없어 넣지 않았습니다)",
+        node: <FoxToast message="입력하신 내용을 저장했습니다. 목록에서 확인할 수 있습니다." />,
+      },
+      {
+        label: "urgent — 모양은 같고 role만 status에서 alert로 바뀝니다 (읽던 것을 끊고 바로 읽음)",
+        node: <FoxToast message="네트워크 연결이 끊겼습니다." urgent />,
+      },
+      {
+        label: "띄우고 지우는 것은 호출부 몫입니다 — 눌러 보세요 (3초 뒤 사라짐, 화면 하단 고정)",
+        node: <ToastDemo />,
+      },
+    ],
+  },
+  {
+    id: "fox-alert",
+    name: "FoxAlert",
+    description:
+      "Figma 시안(alert 258:433) 폭 720px은 시안 값 그대로지만 좁은 자리에서 부모를 뚫지 않게 max-inline-size: 100%를 함께 걸었습니다. 계열은 배경·테두리·제목 글자색을 바꿉니다 — 테두리만 -inverse 토큰이라 연한 배경(5) 위에 한 단계 진한 선(10)이 얹힙니다. 본문 글자색과 아이콘 상자의 흰 배경은 계열과 무관합니다.",
+    variants: [
+      {
+        label: "default",
+        node: (
+          <FoxAlert
+            title="알림 제목이 들어갑니다"
+            message="콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다  콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다  콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다"
+          />
+        ),
+      },
+      {
+        label: "제목만 / 본문만 — 넘기지 않은 쪽은 렌더하지 않습니다",
+        node: (
+          <>
+            <FoxAlert title="제목만 있는 알림" />
+            <FoxAlert message="본문만 있는 알림입니다." />
+          </>
+        ),
+      },
+      {
+        label: "type 5종",
+        node: (
+          <>
+            <FoxAlert type="default" title="default" message="기본" />
+            <FoxAlert type="information" title="information" message="안내" />
+            <FoxAlert type="success" title="success" message="완료" />
+            <FoxAlert type="warning" title="warning" message="주의" />
+            <FoxAlert type="danger" title="danger" message="위험" />
+          </>
+        ),
+      },
+      {
+        label: "좁은 자리(360px)에 넣으면 폭이 부모에 맞춰 줄어듭니다",
+        node: (
+          <div style={{ inlineSize: "36rem", outline: "1px dashed currentColor" }}>
+            <FoxAlert
+              title="좁은 단 안의 알림"
+              message="720px이 아니라 부모 폭까지만 늘어납니다."
+            />
+          </div>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-helper-text",
+    name: "FoxHelperText",
+    description:
+      "Figma 시안(helper-txt) 입력 아래 붙는 안내·오류 한 줄입니다. 계열은 FoxAlert와 같은 다섯 개이고 글리프도 같은 것을 쓰되 굵기만 regular입니다(알럿은 duotone). 계열이 붙으면 글자는 font-system-*, 아이콘은 한 단계 진한 icon-system-*-strong입니다 — default만 글자·아이콘이 같은 회색입니다. 모양 값을 새로 적지 않았습니다 — 시안 값이 _form-field.scss의 message 믹스인과 한 자도 다르지 않아(gap 4px · 아이콘 padding-top 2px · body/sm · 150% · -0.025em) 그대로 부릅니다. 차이는 display 하나뿐이라(시안 inline-flex, 폼 안 flex) 믹스인에 인자로 넘깁니다. ⚠️ 폼 컴포넌트 안의 헬퍼는 아직 이 클래스를 쓰지 않습니다 — FoxInput·FoxTextArea·FoxEmail·FoxPhoneNumber·FoxAddress는 각자 __message를, FoxSelect는 __hint를 그리고, 그쪽 색은 상자의 포커스·오류를 :has()로 따라갑니다(auto-states 믹스인). 모으는 것은 별도 작업입니다.",
+    variants: [
+      {
+        label: "계열 5종 — 아이콘이 글자보다 한 단계 진합니다 (icon-system-*-strong)",
+        node: (
+          <>
+            <FoxHelperText message="기본 안내 문구입니다." />
+            <FoxHelperText type="information" message="입력 형식을 확인해 주세요." />
+            <FoxHelperText type="success" message="사용할 수 있는 아이디입니다." />
+            <FoxHelperText type="warning" message="곧 만료되는 항목입니다." />
+            <FoxHelperText type="danger" message="필수 항목입니다." />
+          </>
+        ),
+      },
+      {
+        label: "showIcon={false} — 글자만 (폼 헬퍼가 아이콘 없이 쓰이는 자리를 위해)",
+        node: (
+          <>
+            <FoxHelperText showIcon={false} message="2자 이상 입력해 주세요." />
+            <FoxHelperText type="danger" showIcon={false} message="필수 항목입니다." />
+          </>
+        ),
+      },
+      {
+        label: "inline-flex라 폭이 글자만큼입니다 (점선은 자리 확인용)",
+        node: (
+          <div style={{ outline: "1px dashed currentColor" }}>
+            <FoxHelperText type="information" message="내용만큼만 차지합니다." />
+          </div>
+        ),
+      },
+      {
+        label: "여러 줄 — 아이콘이 첫 줄에 맞춰 고정됩니다",
+        node: (
+          <div style={{ inlineSize: "24rem" }}>
+            <FoxHelperText
+              type="warning"
+              message="문구가 길어져 두 줄이 되어도 아이콘은 위로 붙어 첫 글줄과 나란히 남습니다."
+            />
+          </div>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-status-indicator",
+    name: "FoxStatusIndicator",
+    description:
+      "Figma 시안(status-indicator 404:6328) 글자로 상태를 알립니다. 점은 장식이라 aria-hidden이고 뜻은 글자가 전부 담습니다 — 그래서 label이 선택값이 아니라 필수값입니다. 색만으로 상태를 전하면 색을 구분하지 못하는 사용자에게는 아무것도 전달되지 않습니다.",
+    variants: [
+      {
+        label: "계열 5종",
+        node: (
+          <>
+            <FoxStatusIndicator label="시작전, 중립적" />
+            <FoxStatusIndicator type="danger" label="중단, 실패, 위험" />
+            <FoxStatusIndicator type="information" label="진행 중" />
+            <FoxStatusIndicator type="warning" label="잠재적 위험, 주의" />
+            <FoxStatusIndicator type="success" label="진행중, 처리중간단계" />
+          </>
+        ),
+      },
+      {
+        label: "inline-flex라 글줄 안에 그대로 놓입니다",
+        node: (
+          <p style={{ margin: 0 }}>
+            현재 상태는 <FoxStatusIndicator type="success" label="정상" /> 입니다.
+          </p>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-snackbar",
+    name: "FoxSnackbar",
+    description:
+      "Figma 시안(snackbar 441:5880) 흰 판 위에 굵은 한 줄과 글자 버튼이 서고 오른쪽 끝에 닫기가 붙습니다. FoxToast와 형제지만 누를 것이 있어서, 읽고 지나가는 토스트와 달리 손이 닿을 때까지 남습니다. 상태를 갖지 않습니다 — onClose는 '닫아 달라'는 알림이지 스스로 사라지는 것이 아니고, 위치도 호출부가 정합니다. 글자 버튼은 FoxTextButton의 primary·md, 닫기는 오른쪽에 붙습니다 — 판의 오른쪽 여백 64px은 글자가 그 아래로 들어가지 않게 비워 두는 자리입니다.",
+    variants: [
+      {
+        label: "기본 — 제목 + 본문 + 액션 + 닫기",
+        node: (
+          <FoxSnackbar
+            title="메세지 타이틀"
+            message="스낵바는 화면 하단에 제공되는 안내 메시지입니다."
+            actionLabel="텍스트 버튼"
+            onAction={() => {}}
+            onClose={() => {}}
+          />
+        ),
+      },
+      {
+        label: "아이콘 — 호출부가 @fox/core/icons에서 골라 넘깁니다 (ico/CheckCircle, weight=fill / 색은 icon-theme-primary)",
+        node: (
+          <FoxSnackbar
+            icon={<FoxCheckCircleIcon weight="fill" />}
+            title="변경 사항을 저장했습니다"
+            message="3분 안에는 되돌릴 수 있습니다."
+            actionLabel="실행 취소"
+            onAction={() => {}}
+            onClose={() => {}}
+          />
+        ),
+      },
+      {
+        label: "제목 없이 / 액션 없이 — 넘기지 않은 쪽은 그리지 않습니다 (닫기는 항상 있습니다)",
+        node: (
+          <>
+            <FoxSnackbar title="제목만 있는 스낵바" actionLabel="보기" onAction={() => {}} onClose={() => {}} />
+            <FoxSnackbar message="제목 없이 본문만 있는 스낵바입니다." actionLabel="보기" onAction={() => {}} onClose={() => {}} />
+            <FoxSnackbar title="액션이 없는 스낵바" message="닫기만 있습니다." onClose={() => {}} />
+          </>
+        ),
+      },
+      {
+        label: "제목이 길면 줄바꿈되고 버튼은 오른쪽에 남습니다",
+        node: (
+          <FoxSnackbar
+            title="제목이 길어져 두 줄이 되어도"
+            message="글자 버튼과 닫기는 자리를 지킵니다. 판은 640px에서 시작하고 오른쪽 64px은 닫기 자리로 비워 둡니다."
+            actionLabel="자세히"
+            onAction={() => {}}
+            onClose={() => {}}
+          />
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-snackbar-list",
+    name: "FoxSnackbarList",
+    description:
+      "Figma 시안(snackbar-list 467:5958) 스낵바가 여러 개일 때 세로로 쌓는 묶음입니다. — 무엇이 몇 개 떠 있는지, 어떤 순서로 쌓을지는 호출부가 소유합니다. 자식에게 덮어쓰는 값이 없다는 점이 FoxTagList·FoxChipArea와 다릅니다.",
+    variants: [
+      {
+        label: "세 개가 쌓인 모습 (간격 8px)",
+        node: (
+          <FoxSnackbarList label="알림">
+            <FoxSnackbar
+              icon={<FoxCheckCircleIcon weight="fill" />}
+              title="변경 사항을 저장했습니다"
+              message="3분 안에는 되돌릴 수 있습니다."
+              actionLabel="실행 취소"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+            <FoxSnackbar
+              title="파일 3개를 올렸습니다"
+              actionLabel="보기"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+            <FoxSnackbar message="제목 없이 본문만 있는 스낵바입니다." onClose={() => {}} />
+          </FoxSnackbarList>
+        ),
+      },
+      {
+        label: "하나만 있어도 그대로 씁니다",
+        node: (
+          <FoxSnackbarList label="알림">
+            <FoxSnackbar title="하나만 떠 있는 경우" onClose={() => {}} />
+          </FoxSnackbarList>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-step-indicator-item",
+    name: "FoxStepIndicatorItem",
+    description:
+      "Figma 시안(step 251:1539) 단계 하나입니다. 위에 동그라미와 선, 아래에 단계 번호(body/xsm)와 제목(body/sm)이 놓입니다. state 세 가지가 동그라미 안과 선 색을 정합니다: completion은 회색으로 채우고 체크를 얹으며 선도 진합니다(여기까지 왔다는 표시), ongoing은 테두리만 강조색이고 가운데 점이 자리를 채우며 선은 흐립니다, before는 흐린 테두리에 연한 면이고 안이 비었습니다. 마지막 단계는 line={false}로 선을 꺼야 합니다 — 켜 두면 묶음 밖으로 삐져나갑니다. 동그라미와 선은 aria-hidden 장식이고 읽히는 것은 글자뿐입니다. 제목 굵기는 시안이 700인데 토큰 세트에 700이 없어(400 regular · 600 medium · 800 bold) 시스템의 bold(800)로 올렸습니다 — Pretendard 기준 Bold보다 한 단계 굵은 ExtraBold입니다. 너무 굵어 보이면 medium으로 내립니다.",
+    variants: [
+      {
+        label: "상태 3종",
+        node: (
+          <>
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="정보 입력" />
+            <FoxStepIndicatorItem state="before" step="STEP 03" title="가입 완료" />
+          </>
+        ),
+      },
+      {
+        label: "글자 없이 — 동그라미와 선만 (모바일에서 자동으로 이 모양이 됩니다)",
+        node: (
+          <>
+            <FoxStepIndicatorItem state="completion" />
+            <FoxStepIndicatorItem state="ongoing" />
+            <FoxStepIndicatorItem state="before" />
+          </>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-step-indicator",
+    name: "FoxStepIndicator",
+    description:
+      "Figma 시안(step-indicator 708:1623) FoxStepIndicatorItem들을 가로로 늘어놓는 묶음입니다. 배치만 갖고 상태는 없습니다 — 어디까지 왔는지는 각 단계의 state가 말합니다. 자식에게 덮어쓰는 값이 없어서 마지막 단계의 line={false}는 호출부가 줍니다: 묶음이 '마지막'을 대신 판단하면 자식을 조건부로 렌더하거나 프래그먼트로 감싼 호출부에서 어긋납니다. role=list가 아니라 role=group입니다 — 목록이 아니라 하나의 진행 상황이고, 진행률을 소리로 전해야 하면 label에 담습니다. ⚠️ 폭 400px은 80px × 5단계라 단계가 다섯 개일 때의 값입니다. 개수가 다르면 className으로 덮습니다. 화면을 좁히면 글자가 사라지고 동그라미 줄만 남습니다.",
+    variants: [
+      {
+        label: "5단계 — 시안 폭(400px)에 맞는 개수",
+        node: (
+          <FoxStepIndicator label="가입 절차 (전체 5단계 중 3단계)">
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" />
+            <FoxStepIndicatorItem state="completion" step="STEP 02" title="본인 인증" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 03" title="정보 입력" />
+            <FoxStepIndicatorItem state="before" step="STEP 04" title="결제 수단" />
+            <FoxStepIndicatorItem state="before" step="STEP 05" title="가입 완료" line={false} />
+          </FoxStepIndicator>
+        ),
+      }, 
+      {
+        label: "3단계 — 폭이 고정이라 오른쪽이 남습니다 (className으로 덮는 자리)",
+        node: (
+          <FoxStepIndicator label="결제 절차">
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="장바구니" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="결제" />
+            <FoxStepIndicatorItem state="before" step="STEP 03" title="완료" line={false} />
+          </FoxStepIndicator>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-popover",
+    name: "FoxPopover",
+    description:
+      "Figma 시안(popover 251:1813) 꼬리가 달린 말풍선 판입니다. direction이 꼬리가 판의 위(top)에 붙는지 아래(bottom)에 붙는지, arrow가 가로 어디(left/center/right)에 붙는지 정합니다 — top이면 트리거 아래에 뜨는 말풍선입니다. 자리를 스스로 잡지 않습니다: 트리거 옆 어디에 놓을지는 className으로 주고, 자리가 모자랄 때 위아래를 뒤집는 자동 배치도 없습니다(시안이 방향을 값으로 주므로 그 판단은 호출부 몫입니다). 액션은 FoxTextButton의 secondary·sm에 오른쪽 아이콘입니다. 꼬리의 가로 위치는 루트의 align-items가 정합니다(시안 지정) — 꼬리 영역이 좌우 24px 여백을 달고 다녀서 왼쪽·오른쪽에서는 그만큼 안쪽에 서고, 가운데에서는 여백이 대칭이라 정확히 가운데에 섭니다. 판은 align-self: stretch라 이 값에 영향받지 않고 언제나 폭을 꽉 채웁니다.",
+    variants: [
+      {
+        label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)",
+        node: (
+          <>
+            <FoxPopover
+              direction="bottom"
+              title="팝오버 제목"
+              message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
+              actionLabel="바로가기"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+            <FoxPopover
+              direction="bottom"
+              arrow="center"
+              title="팝오버 제목"
+              message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
+              actionLabel="바로가기"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+            <FoxPopover
+              direction="bottom"
+              arrow="right"
+              title="팝오버 제목"
+              message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
+              actionLabel="바로가기"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+          </>
+          
+        ),
+      },
+      {
+        label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)",
+        node: (
+          <>
+            <FoxPopover
+              direction="top"
+              arrow="left"
+              title="팝오버 제목"
+              message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
+              actionLabel="바로가기"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+            <FoxPopover
+              direction="top"
+              arrow="center"
+              title="팝오버 제목"
+              message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
+              actionLabel="바로가기"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+            <FoxPopover
+              direction="top"
+              arrow="right"
+              title="팝오버 제목"
+              message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
+              actionLabel="바로가기"
+              onAction={() => {}}
+              onClose={() => {}}
+            />
+          </>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-tooltip-rich",
+    name: "FoxTooltipRich",
+    description:
+      "Figma 시안(tooltip-rich 256:233) 제목과 본문을 담은 말풍선입니다. 150자 내외의 글자만 담습니다. 그래서 여닫는 것은 전적으로 트리거의 몫입니다(가리키거나 포커스가 닿는 동안 보이는 식). 팝오버와 다른 점은 셋뿐입니다: 테두리가 한 단계 옅고(border-neutral-default), 제목과 본문 사이가 좁으며(8px ↔ 16px), 그림자가 없습니다. 꼬리는 팝오버와 같은 그림이라 _bubble.scss의 arrow() 믹스인을 함께 씁니다 — 회전한 정사각형의 절반을 판에 걸쳐 이음새를 없애는 방식입니다. ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않습니다: 툴팁으로 읽히려면 트리거가 aria-describedby로 id를 가리켜야 하고, 그 배선은 트리거 쪽 몫입니다.",
+    variants: [
+      {
+        label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)",
+        node: (
+          <>
+            <FoxTooltipRich
+              direction="bottom"
+              title="툴팁제목"
+              message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
+            />
+            <FoxTooltipRich
+              direction="bottom"
+              arrow="center"
+              title="툴팁제목"
+              message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
+            />
+            <FoxTooltipRich
+              direction="bottom"
+              arrow="right" 
+              title="툴팁제목"
+              message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
+            />
+          </>
+        ),
+      },
+      {
+        label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)",
+        node: (
+          <>
+            <FoxTooltipRich
+              direction="top"
+              title="툴팁제목"
+              message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
+            />
+            <FoxTooltipRich
+              direction="top"
+              arrow="center"
+              title="툴팁제목"
+              message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
+            />
+            <FoxTooltipRich
+              direction="top"
+              arrow="right"
+              title="툴팁제목"
+              message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
+            />
+          </>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-tooltip",
+    name: "FoxTooltip",
+    description:
+      "Figma 시안(tooltip) 어두운 판에 짧은 글 한 줄을 담은 말풍선입니다. FoxTooltipRich의 작은 형제로 제목이 없고, 꼬리가 언제나 가운데이며(가로 위치를 고르는 값이 없습니다), 판이 글자만큼만 넓어집니다. 여기도 누를 것이 하나도 없습니다 — 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없습니다. 꼬리는 팝오버·리치 툴팁과 달리 시안 SVG를 그대로 씁니다: 저쪽은 테두리 있는 판의 1px 이음새를 지우려고 CSS로 그렸지만 이 판은 테두리 없이 한 색이라 그럴 일이 없고, 시안 꼬리가 12×6에 끝이 둥근 다른 그림이라 CSS 삼각형으로는 모양이 나오지 않습니다. 같은 그림을 180° 돌려 두 방향을 만듭니다. ⚠️ 판이 글자만큼 좁아져도 꼬리 영역의 좌우 24px 여백 때문에 가장 좁을 때가 60px입니다(시안 값). ⚠️ 이 조각도 보조기술에 역할을 주지 않습니다 — 트리거가 aria-describedby로 id를 가리켜야 합니다.",
+    variants: [
+      {
+        label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)",
+        node: <FoxTooltip direction="top" message="툴팁 내용" />,
+      },
+      {
+        label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)",
+        node: <FoxTooltip direction="bottom" message="툴팁 내용" />,
+      },
+      {
+        label: "글자 길이에 따라 판이 넓어집니다 (가장 좁을 때 60px)",
+        node: (
+          <>
+            <FoxTooltip message="짧게" />
+            <FoxTooltip message="조금 더 긴 안내 문구입니다" />
+          </>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-contextual-trigger",
+    name: "FoxContextualTrigger",
+    description:
+      "Figma 시안(contextual-trigger) 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추입니다. 계열이 아이콘을 정하고(help는 ico/Question, information은 ico/Info, 둘 다 weight=regular) 상태는 배경만 바꿉니다 — 기본이 action-secondary(투명)라 아래 배경이 그대로 비치고 hover·pressed에서만 연한 판이 깔립니다(FoxTag와 같은 규칙). FoxIconButton과는 다른 조각입니다: 저쪽은 폼 컨트롤과 높이를 맞추느라 가장 작은 xsm도 32px인데 이쪽은 글줄 옆에 붙는 24px 고정이고 모서리도 4px입니다. 상태를 갖지 않으므로 무엇을 언제 보여 줄지는 호출부가 정하고, 이 단추는 배선을 걸 자리(describedBy / controls·expanded)와 손이 닿는 자리를 줍니다. 아이콘만 있는 단추라 aria-label이 유일한 이름입니다 — 한 화면에 여럿이면 '배송비 도움말'처럼 무엇에 대한 것인지 적어 줍니다. ⚠️ 비활성은 시안에 없어 색을 만들지 않고 hover·pressed만 막았습니다.",
+    variants: [
+      {
+        label: "계열 2종 — hover·pressed는 마우스를 올리고 눌러서 확인하세요",
+        node: (
+          <>
+            <FoxContextualTrigger type="help" />
+            <FoxContextualTrigger type="information" />
+          </>
+        ),
+      },
+      {
+        label: "실제 배선 — 가리키면 툴팁 / 누르면 팝오버 / 가리키면 리치 툴팁",
+        node: <ContextualTriggerDemo />,
+      },
+    ],
+  },
 ];
 
@fox/styles/_bubble.scss (added)
+++ @fox/styles/_bubble.scss
@@ -0,0 +1,127 @@
+// 말풍선 공용 조각.
+//
+// 꼬리가 달린 판(`FoxPopover` · `FoxTooltipRich`)이 같은 그림을 쓴다. 그 되풀이를 여기로 모은다.
+// 믹스인만 있고 규칙이 없어 @use 해도 CSS가 늘지 않는다 — 부르는 자리에서만 나온다.
+//
+// ── 꼬리를 CSS로 그리는 이유 ───────────────────────────────────────────────────
+//
+// 시안은 꼬리를 22×12 SVG로 준다. 그 그림에는 평평한 끝을 덮는 1px 흰 띠가 들어 있는데,
+// 꼬리를 판 위로 1px 겹쳐 올려 그 띠로 판의 1px 테두리를 지우는 구조다. Figma에서는 맞지만
+// 브라우저에서는 **1px 두 개를 정확히 겹쳐야 하는 방식**이라, 소수 배율 화면(윈도우 125% 등)에서
+// 둘이 서로 다른 물리 픽셀에 떨어지면 이음새에 실선이 비친다. 실제로 그렇게 보였다.
+//
+// 그래서 CSS로 그린다. **회전한 정사각형의 절반을 판 위에 걸치는** 방식이다:
+//
+//   1. 한 변이 22 ÷ √2(≈15.56px)인 정사각형을 45° 돌린다 → 밑변 22px, 높이 11px의 마름모
+//   2. 그 마름모의 **가운데를 판과 맞닿는 변 위에** 둔다
+//   3. 바깥을 향하는 두 변에만 1px 테두리를 준다 (안쪽 두 변은 테두리 없음)
+//
+// 그러면 안쪽 절반(11px)이 판의 테두리 선을 자기 배경으로 덮는다 — 1px끼리 맞추는 것이 아니라
+// 11px짜리 면이 1px 선을 덮으므로 어떤 배율에서도 이음새가 남지 않는다. 바깥 두 변의 테두리는
+// 마름모의 좌우 꼭짓점, 즉 판의 테두리와 같은 높이에서 끝나므로 선이 자연스럽게 이어진다.
+//
+// 45°로 돌리면 밑변과 높이의 비가 2:1이라 22×11이 된다. 시안은 22×12이므로 돌린 뒤 세로로
+// 12/11만큼 늘인다(CSS 변환은 오른쪽부터 적용되므로 `scaleY(...) rotate(45deg)` 순서다).
+// 빗변 테두리가 그만큼 두꺼워지지만 1.09배라 눈에 띄지 않는다.
+//
+// SVG를 쓰지 않으니 마스크 id가 문서에서 겹칠 일도 없다(시안 SVG를 그대로 넣으면 말풍선이
+// 둘 이상 뜰 때 같은 id를 서로 참조한다).
+//
+// 토큰이 아닌 값:
+//   - ⚠️ 꼬리가 차지하는 자리 22×12px: 시안이 준 그림의 크기이고 토큰 스케일에 없다.
+//   - ⚠️ 정사각형의 한 변 `1.5556rem`: 밑변이 22px이 되는 값(22 ÷ √2)이다. 밑변을 바꾸려면
+//     이 값을 √2로 나눠 다시 구한다.
+//   - ⚠️ `scaleY(12 / 11)`: 45° 마름모의 높이(11px)를 시안의 12px로 맞추는 보정이다.
+//   - 시안의 `gap: -1px`은 옮기지 않았다 — 정사각형의 안쪽 절반이 이미 판 위에 걸쳐 테두리를
+//     덮으므로 따로 당길 필요가 없다.
+
+@use "@fox/styles/abstracts" as fox;
+
+/// 말풍선 바깥 틀. 꼬리와 판이 세로로 서고, 꼬리의 가로 위치를 이 정렬이 정한다.
+@mixin root($inline-size: 36rem) {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  inline-size: $inline-size;
+}
+
+/// 꼬리와 방향·위치 규칙 한 벌.
+///
+/// `$block`은 BEM 블록 이름(`"fox-popover"` 등)이고, `$border`·`$fill`은 판과 **같은 값**을
+/// 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이라 색이 갈리면 안 된다.
+///
+/// 부르는 쪽은 다음 마크업을 갖는다:
+///   <div class="…__arrow-area"><span class="…__arrow"></span></div>
+@mixin arrow($block, $border, $fill) {
+  $root: ".#{$block}";
+
+  // 좌우 여백이 꼬리를 판 가장자리에서 띄운다. **늘리지 않는다** — 폭이 내용(22px + 여백
+  // 48px)만큼이라야 루트의 `align-items`가 이 덩어리를 왼쪽·가운데·오른쪽으로 옮길 수 있다.
+  //
+  // 판보다 위에 칠해진다. 마름모의 안쪽 절반이 판의 테두리를 덮어야 하는데, `--top`에서는
+  // 꼬리가 DOM에서 먼저 와 판이 나중에 덧칠되기 때문이다.
+  #{$root}__arrow-area {
+    position: relative;
+    z-index: 1;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    padding-inline: fox.padding(8);
+  }
+
+  // 자리만 잡는다 — 그림은 아래 `::before`가 그린다(파일 상단 주석 참고).
+  #{$root}__arrow {
+    position: relative;
+    display: block;
+    inline-size: 2.2rem;
+    block-size: 1.2rem;
+  }
+
+  #{$root}__arrow::before {
+    content: "";
+    position: absolute;
+    inset-inline-start: 50%;
+    // 밑변이 22px이 되는 정사각형의 한 변: 22 ÷ √2.
+    inline-size: 1.5556rem;
+    block-size: 1.5556rem;
+    border-style: solid;
+    border-color: $border;
+    border-width: 0;
+    background: $fill;
+  }
+
+  // 정사각형의 가운데를 판과 맞닿는 변에 두고, 바깥을 향하는 두 변에만 테두리를 준다.
+  // 45° 돌리면 원래의 위·왼쪽 변이 위쪽 두 빗변이 되고, 아래·오른쪽 변이 아래쪽 두 빗변이 된다.
+  #{$root}--top #{$root}__arrow::before {
+    inset-block-start: 100%;
+    border-block-start-width: fox.border(1);
+    border-inline-start-width: fox.border(1);
+    transform: translate(-50%, -50%) scaleY(calc(12 / 11)) rotate(45deg);
+  }
+
+  #{$root}--bottom {
+    justify-content: flex-end;
+  }
+
+  #{$root}--bottom #{$root}__arrow::before {
+    inset-block-start: 0;
+    border-block-end-width: fox.border(1);
+    border-inline-end-width: fox.border(1);
+    transform: translate(-50%, -50%) scaleY(calc(12 / 11)) rotate(45deg);
+  }
+
+  // 꼬리의 가로 위치는 루트의 정렬이 정한다(시안 지정). 판은 `align-self: stretch`라 이 값에
+  // 영향받지 않고 언제나 폭을 꽉 채운다 — 움직이는 것은 꼬리뿐이다.
+  #{$root}--arrow-left {
+    align-items: flex-start;
+  }
+
+  #{$root}--arrow-center {
+    align-items: center;
+  }
+
+  #{$root}--arrow-right {
+    align-items: flex-end;
+  }
+}
@fox/styles/_form-field.scss
--- @fox/styles/_form-field.scss
+++ @fox/styles/_form-field.scss
@@ -27,8 +27,14 @@
 }
 
 /// 시안 helper-message. 오류·포커스 색은 상태 규칙이 덮는다.
-@mixin message {
-  display: flex;
+///
+/// `$display`는 폼 안에서 `flex`이고, 단독으로 쓰는 `FoxHelperText`에서만
+/// `inline-flex`다 — 그쪽 시안이 내용만큼만 차지하게 준다.
+///
+/// TODO(폼 통합, 적용 후 이 문단 삭제): 이 믹스인을 부르는 폼 다섯 곳은 앞으로
+/// `FoxHelperText`(`.fox-helper-text`)로 옮긴다 — 절차는 `_fox-helper-text.scss` 상단 참고.
+@mixin message($display: flex) {
+  display: $display;
   gap: fox.number(3);
   align-items: flex-start;
   margin: 0;
 
@fox/styles/_fox-alert.scss (added)
+++ @fox/styles/_fox-alert.scss
@@ -0,0 +1,166 @@
+// FoxAlert — 시안: 통합관리자페이지 디자인시스템 Figma alert
+//
+// 화면 안에 놓이는 띠 모양 알림이다. 아이콘 상자 · 제목 · 본문이 한 줄로 선다.
+// **모달이 아니다** — 화면을 덮지 않고 내용 흐름 안에 그대로 놓인다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-alert fox-alert--default" role="status">
+//     <span class="fox-alert__icon">…아이콘…</span>
+//     <div class="fox-alert__content">
+//       <strong class="fox-alert__title">제목</strong>
+//       <span class="fox-alert__message">본문</span>
+//     </div>
+//   </div>
+//
+// 제목·본문은 각각 없을 수 있다. 아이콘 상자는 아이콘이 없어도 그린다 — 시안이 흰 판을
+// 배치의 일부로 주고, 있고 없고에 따라 글자 시작점이 흔들리면 안 된다.
+//
+// 제목이 `heading/xsm`(title 패밀리·bold)이고 본문이 `body/md`(body 패밀리·regular)다.
+// 글자 크기는 둘 다 15px이라 굵기와 패밀리로만 갈린다 — 시안이 그렇게 준다.
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 72rem`: **시안의 폭(720px)을 일단 그대로 적용해 둔 것이다.** Figma가
+//     변수로 export하지 않았고 토큰 스케일에도 720이 없다(modal 계열의 가장 큰 값이 760px).
+//     토큰이 생기면 `fox.*()`로 바꾸고, 폭을 고정하지 않는 쪽으로 정해지면 여기서 뺀다.
+//     그때까지 폭을 바꿔야 하는 자리는 호출부가 `className`으로 덮는다
+//     (`fox-toggle-quantity`와 같은 처리).
+//   - `max-inline-size: 100%`: 시안에 없는 구조 규칙이다. 720px보다 좁은 자리에 놓였을 때
+//     띠가 부모를 뚫고 나가지 않게 막는다 — 고정 폭만 두면 표 안이나 좁은 단에서 넘친다.
+//   - line-height / letter-spacing: 시안 텍스트 스타일 heading/xsm·body/md의 값이나 Figma가
+//     변수로 export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다
+//     (다른 컴포넌트와 같은 근거).
+//   - box-sizing / display / flex / aspect-ratio: 구조 규칙.
+//
+// 아이콘은 컴포넌트의 `TYPE_ICON`이 계열마다 하나씩 쥐고 있다(Prohibit · Warning ·
+// CheckCircle · Info · ChatDots, 전부 `weight="duotone"`). 색은 **제목 글자와 같은 계열색**을
+// 쓴다(default는 `font-neutral-strong`) — 시안 확인 완료.
+//
+// duotone은 연한 면과 진한 선을 겹쳐 그리고 **둘 다 `currentColor`를 쓴다** — 연한 쪽은
+// 글리프 자체가 `opacity: 0.2`를 물고 있어 색을 하나만 줘도 두 톤이 나온다.
+//
+// duotone은 연한 면과 진한 선을 겹쳐 그리고 **둘 다 `currentColor`를 쓴다** — 연한 쪽은
+// 글리프 자체가 `opacity: 0.2`를 물고 있어 색을 하나만 줘도 두 톤이 나온다.
+//
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-alert {
+  // 안쪽 여백이 바깥 폭을 늘리지 않게 한다 — 720px이 720px로 그려진다.
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  gap: fox.gap(4);
+  inline-size: 72rem;
+  max-inline-size: 100%;
+  padding: fox.padding(5) fox.padding(6);
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(4);
+  background: fox.color(surface-neutral-gray);
+
+  // 정사각 흰 판. 글자가 길어져 띠가 늘어나도 크기가 그대로여야 해서 줄어들지 않게 막는다.
+  &__icon {
+    display: flex;
+    flex-shrink: 0;
+    justify-content: center;
+    align-items: center;
+    inline-size: fox.number(13);
+    block-size: fox.number(13);
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(4);
+    background: fox.color(element-neutral-inverse);
+    // 글리프가 `fill: currentColor`라 이 색을 그대로 입는다. 계열이 있으면 아래에서 덮는다.
+    color: fox.color(font-neutral-strong);
+  }
+
+  // 글리프는 흰 판(36px)보다 작은 24px이다 — 판이 글리프를 감싸는 여백을 갖는다.
+  // 상자와 마찬가지로 줄어들지 않게 막고 정사각을 못박는다(시안이 둘 다 명시한다).
+  &__icon > * {
+    flex-shrink: 0;
+    inline-size: fox.icon(5);
+    block-size: fox.icon(5);
+    aspect-ratio: 1 / 1;
+  }
+
+  // 남는 폭을 전부 가져간다. `min-inline-size: 0`이 없으면 긴 글이 줄바꿈되지 않고 띠를
+  // 밀어 넓힌다(flex 자식의 기본 최소 크기가 내용 크기라서다).
+  &__content {
+    display: flex;
+    flex: 1 0 0;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(1);
+    min-inline-size: 0;
+  }
+
+  &__title {
+    align-self: stretch;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(heading-xsm);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+  }
+
+  &__message {
+    align-self: stretch;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // ── 계열 ────────────────────────────────────────────────────────────────────
+  // 계열이 바꾸는 것은 **배경 · 테두리 · 제목 글자색** 셋뿐이다. 본문 글자색과 아이콘 상자의
+  // 흰 배경은 계열과 무관하게 블록 규칙을 그대로 쓴다(시안 확인).
+  //
+  // 테두리만 `-inverse`다 — 같은 계열의 5(연한 배경)와 10(그보다 한 단계 진한 선)을 짝지어
+  // 쓰라고 토큰이 그렇게 갈라져 있다. 다크 모드에서는 세 값이 함께 뒤집힌다(95 / 90 / 20).
+  //
+  // `default`는 블록 규칙이 곧 그 계열의 모양이라 따로 덮을 것이 없다.
+  &--default {
+    // 시안 그대로 — 블록에 있는 값이 전부다.
+  }
+
+  &--information {
+    border-color: fox.color(border-system-information-inverse);
+    background: fox.color(surface-system-information);
+  }
+
+  &--information &__title,
+  &--information &__icon {
+    color: fox.color(font-system-information);
+  }
+
+  &--success {
+    border-color: fox.color(border-system-success-inverse);
+    background: fox.color(surface-system-success);
+  }
+
+  &--success &__title,
+  &--success &__icon {
+    color: fox.color(font-system-success);
+  }
+
+  &--warning {
+    border-color: fox.color(border-system-warning-inverse);
+    background: fox.color(surface-system-warning);
+  }
+
+  &--warning &__title,
+  &--warning &__icon {
+    color: fox.color(font-system-warning);
+  }
+
+  &--danger {
+    border-color: fox.color(border-system-danger-inverse);
+    background: fox.color(surface-system-danger);
+  }
+
+  &--danger &__title,
+  &--danger &__icon {
+    color: fox.color(font-system-danger);
+  }
+}
 
@fox/styles/_fox-contextual-trigger.scss (added)
+++ @fox/styles/_fox-contextual-trigger.scss
@@ -0,0 +1,75 @@
+// FoxContextualTrigger — 시안: 통합관리자페이지 디자인시스템 Figma contextual-trigger
+//
+// 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추다. 계열이 아이콘을 정하고(help는 물음표,
+// information은 느낌표), 상태는 배경만 바꾼다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <button type="button" class="fox-contextual-trigger fox-contextual-trigger--help"
+//           aria-label="도움말">
+//     <span class="fox-contextual-trigger__icon">…아이콘 svg…</span>
+//   </button>
+//
+// 루트가 `<button>`이라 hover·pressed·포커스·키보드가 전부 네이티브다. 시안의 pressed는
+// `:active`가 받는다.
+//
+// `fox-icon-button`과 다른 조각이다. 저쪽은 폼 컨트롤과 높이를 맞추는 단추라 크기가
+// `form(height-*)`을 따르는데(가장 작은 xsm도 32px), 이쪽은 글줄 옆에 붙는 24px 고정이고
+// 모서리도 `radius-2`(4px)다.
+//
+// 배경은 세 상태 모두 `action-secondary` 계열이다 — 기본이 투명이라 아래 배경이 그대로
+// 비치고, hover·pressed에서만 연한 판이 깔린다(`fox-tag`와 같은 규칙).
+//
+// ⚠️ 비활성은 시안에 없다. 색을 임의로 만들지 않고 hover·pressed만 막는다 — 네이티브
+// `disabled`를 주면 커서와 클릭만 죽고 모양은 기본과 같다(`fox-tag`와 같은 처리).
+//
+// 토큰이 아닌 값:
+//   - box-sizing / display / flex / aspect-ratio / cursor: 구조·상호작용 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-contextual-trigger {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  inline-size: fox.number(10);
+  block-size: fox.number(10);
+  aspect-ratio: 1 / 1;
+  padding: 0;
+  border: none;
+  border-radius: fox.radius(2);
+  background: fox.color(action-secondary);
+  color: fox.color(icon-neutral-default);
+  cursor: pointer;
+
+  &:disabled {
+    cursor: default;
+  }
+
+  // 아이콘은 판(24px)보다 작은 20px이다 — 판이 아이콘을 감싸는 여백을 갖는다.
+  &__icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    inline-size: fox.icon(4);
+    block-size: fox.icon(4);
+    aspect-ratio: 1 / 1;
+  }
+
+  // 넘긴 SVG가 슬롯을 꽉 채운다 — 크기는 슬롯이 정하고 SVG는 따라온다.
+  &__icon > * {
+    inline-size: 100%;
+    block-size: 100%;
+  }
+
+  // ── 상태 ────────────────────────────────────────────────────────────────────
+  // 계열과 무관하게 배경만 바뀐다.
+  &:hover:not(:disabled) {
+    background: fox.color(action-secondary-hover);
+  }
+
+  &:active:not(:disabled) {
+    background: fox.color(action-secondary-pressed);
+  }
+}
@fox/styles/_fox-file-upload.scss
--- @fox/styles/_fox-file-upload.scss
+++ @fox/styles/_fox-file-upload.scss
@@ -127,10 +127,12 @@
     block-size: fox.icon(6);
   }
 
+  // 잘라내지 않고 상자 안에 다 보이게 담는다 — 고른 그림이 어떻게 생겼는지 확인하는
+  // 자리라 일부만 보이면 확인이 되지 않는다.
   &__thumb-image {
     inline-size: 100%;
     block-size: 100%;
-    object-fit: cover;
+    object-fit: contain;
   }
 
   &__thumb-remove {
 
@fox/styles/_fox-heading-group.scss (added)
+++ @fox/styles/_fox-heading-group.scss
@@ -0,0 +1,70 @@
+// FoxHeadingGroup — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-heading-group">
+//     <div class="fox-heading-group__text">
+//       <div class="fox-heading-group__heading"><h2 class="fox-heading-group__title">제목</h2></div>
+//       <p class="fox-heading-group__description">설명</p>          <!-- 선택 -->
+//     </div>
+//     <div class="fox-heading-group__actions">…</div>               <!-- 선택 -->
+//   </div>
+//
+// 제목 줄이 따로 있는 이유는 시안이 그 안에 아이콘·배지를 함께 세울 자리를 두기 때문이다
+// (지금은 제목 하나뿐이지만 8px 간격이 이미 잡혀 있다).
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일 display/sm의 값이나 Figma가 변수로
+//     export하지 않는다. -0.65px은 26px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은 근거).
+//   - margin 0: 제목 태그의 브라우저 기본값 지우기.
+
+@use "abstracts" as fox;
+
+.fox-heading-group {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(5);
+  // 뒤따르는 도구 줄·표가 간격을 따로 두지 않게 이 조각이 아래 여백을 갖는다.
+  padding-block-end: fox.spacing(bottom-md);
+
+  &__text {
+    display: flex;
+    flex: 1 0 0;
+    min-inline-size: 0;
+    flex-direction: column;
+  }
+
+  &__heading {
+    display: flex;
+    inline-size: 100%;
+    align-items: center;
+    gap: fox.gap(3);
+  }
+
+  &__title {
+    margin: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(display-sm);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__description {
+    margin: 0;
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__actions {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    gap: fox.gap(3);
+  }
+}
 
@fox/styles/_fox-helper-text.scss (added)
+++ @fox/styles/_fox-helper-text.scss
@@ -0,0 +1,105 @@
+// FoxHelperText — 시안: 통합관리자페이지 디자인시스템 Figma helper-txt
+//
+// 입력 아래 한 줄로 붙는 안내·오류 문구다. 아이콘 하나와 글자 한 덩어리로 끝난다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <p class="fox-helper-text fox-helper-text--danger">
+//     <span class="fox-helper-text__icon">…아이콘 svg…</span>
+//     <span class="fox-helper-text__message">필수 항목입니다.</span>
+//   </p>
+//
+// 아이콘은 없어도 된다(글자만 있는 자리가 많다). 계열은 `FoxAlert`와 같은 다섯 개이고,
+// 글리프도 같은 것을 쓰되 **굵기만 regular**다(알럿은 duotone).
+//
+// **모양 값을 여기서 새로 적지 않는다.** 시안 값이 `_form-field.scss`의 `message`·
+// `message-icon` 믹스인과 한 자도 다르지 않아(gap 4px · 아이콘 padding-top 2px · body/sm ·
+// line-height 150% · letter-spacing -0.025em) 그대로 부른다. 폼 컴포넌트의 `__message`가
+// 같은 믹스인을 쓰므로, 두 경로가 갈라질 수 없다.
+//
+// 유일한 차이가 `display`다 — 시안이 `inline-flex`를 준다(폼 안에서는 `flex`). 문구가
+// 글줄 안에 놓일 수 있어서 폭을 내용만큼만 갖는 쪽이 맞다.
+//
+// 계열은 **글자와 아이콘의 색만** 바꾼다. `default`는 믹스인이 정한 회색
+// (`font-neutral-subtle`)이고, 나머지 넷은 알럿 제목과 같은 `font-system-*`이다. 시안 확인 완료.
+//
+// 아이콘 크기도 믹스인이 정한 16px(`icon-3`)이 시안 값이다 — 폼 안 헬퍼와 같다(시안 확인 완료).
+//
+// TODO(폼 통합, 적용 후 이 주석 삭제): 폼 컴포넌트 여섯 곳의 헬퍼를 이 클래스로 모은다.
+// 지금은 각자 그린다 — FoxInput · FoxTextArea · FoxEmail · FoxPhoneNumber · FoxAddress가
+// `__message`, FoxSelect가 `__hint`다. 모양은 `_form-field.scss`의 `message` 믹스인 한
+// 곳에서 나와 어긋나지는 않지만 마크업이 여섯 벌이다.
+//
+// 옮길 때 할 일:
+//   1) `FoxHelperText`에 `icon` 오버라이드 prop을 더한다 — 폼은 호출부가 준 아이콘을 쓰고
+//      계열 글리프를 쓰지 않는다.
+//   2) tsx의 `<p class="fox-X__message">`를 `<FoxHelperText className="fox-X__message" …>`로
+//      바꾼다. **클래스를 남기는 것이 요점이다** — 그래야 상태 색 규칙(`auto-states`의
+//      `:focus-within`·`:has([aria-invalid])`)이 선택자를 고치지 않고 그대로 먹는다.
+//   3) 각 컴포넌트 scss에서 `&__message { @include field.message; }`와
+//      `&__message-icon { … }` 블록을 지운다 — `.fox-helper-text`가 대신 준다.
+//   4) 아이콘은 `showIcon={Boolean(messageIcon)}`으로 넘겨 기존 동작(아이콘을 안 주면 안
+//      그린다)을 유지한다.
+//
+// ⚠️ 옮기면 **FoxSelect만 모양이 달라진다.** 진행 전에 확인받을 것:
+//   - 힌트 간격이 `gap 8px` · `align-items: center`에서 `4px` · `flex-start`로 바뀐다
+//     (helper-txt 시안 값이 이쪽이다).
+//   - 오류일 때 아이콘 색이 `icon-system-danger`(danger-50)에서, 글자색을 상속한
+//     `font-system-danger`(danger-60)로 한 단계 진해진다.
+//
+// 나머지 다섯은 값이 같아 화면이 바뀌지 않는다. 앱·다른 컴포넌트가 `__message`·`__hint`
+// 클래스를 직접 참조하는 곳은 없다(확인 완료).
+
+@use "@fox/styles/abstracts" as fox;
+@use "@fox/styles/form-field" as field;
+
+.fox-helper-text {
+  // 시안이 inline-flex다(폼 안에서는 flex) — 믹스인에 인자로 넘겨 한 번만 적는다.
+  @include field.message($display: inline-flex);
+
+  &__icon {
+    @include field.message-icon;
+  }
+
+  // ── 계열 ────────────────────────────────────────────────────────────────────
+  // 글자와 아이콘의 색이 **다르다** — 글자는 `font-system-*`, 아이콘은 한 단계 진한
+  // `icon-system-*-strong`이다(시안 확인). 아이콘 글리프가 `fill: currentColor`라 슬롯에
+  // 색을 주면 그대로 입는다.
+  //
+  // `default`만 예외다 — 글자도 아이콘도 믹스인이 정한 회색(`font-neutral-subtle`)을 함께
+  // 쓰므로 따로 덮을 것이 없다.
+  &--default {
+    // 시안 그대로 — 믹스인에 있는 값이 전부다.
+  }
+
+  &--information {
+    color: fox.color(font-system-information);
+  }
+
+  &--information &__icon {
+    color: fox.color(icon-system-information-strong);
+  }
+
+  &--success {
+    color: fox.color(font-system-success);
+  }
+
+  &--success &__icon {
+    color: fox.color(icon-system-success-strong);
+  }
+
+  &--warning {
+    color: fox.color(font-system-warning);
+  }
+
+  &--warning &__icon {
+    color: fox.color(icon-system-warning-strong);
+  }
+
+  &--danger {
+    color: fox.color(font-system-danger);
+  }
+
+  &--danger &__icon {
+    color: fox.color(icon-system-danger-strong);
+  }
+}
@fox/styles/_fox-input.scss
--- @fox/styles/_fox-input.scss
+++ @fox/styles/_fox-input.scss
@@ -7,6 +7,7 @@
 //     <div class="fox-input__box">
 //       <input class="fox-input__field" id="…" placeholder="…">
 //       <button class="fox-input__clear" type="button">…svg…</button>   <!-- 선택 -->
+//       <span class="fox-input__suffix">/ 총 39개</span>                  <!-- 선택 -->
 //       <span class="fox-input__icon">…svg…</span>                      <!-- 선택 -->
 //     </div>
 //     <p class="fox-input__message">…</p>                               <!-- 선택 -->
@@ -69,6 +70,13 @@
     cursor: pointer;
   }
 
+  // 아이콘 슬롯과 달리 정사각형으로 못박지 않는다 — 글자만큼 차지하고 줄어들지 않는다.
+  &__suffix {
+    flex-shrink: 0;
+    color: fox.color(font-neutral-default);
+    white-space: nowrap;
+  }
+
   &__message {
     @include field.message;
   }
@@ -84,7 +92,8 @@
     border-radius: fox.form(radius-lg);
   }
 
-  &--lg &__field {
+  &--lg &__field,
+  &--lg &__suffix {
     font-size: fox.font-size(label-lg);
   }
 
@@ -100,7 +109,8 @@
     border-radius: fox.form(radius-md);
   }
 
-  &--md &__field {
+  &--md &__field,
+  &--md &__suffix {
     font-size: fox.font-size(label-md);
   }
 
@@ -116,7 +126,8 @@
     border-radius: fox.form(radius-sm);
   }
 
-  &--sm &__field {
+  &--sm &__field,
+  &--sm &__suffix {
     font-size: fox.font-size(label-sm);
   }
 
@fox/styles/_fox-list-search.scss
--- @fox/styles/_fox-list-search.scss
+++ @fox/styles/_fox-list-search.scss
@@ -64,12 +64,22 @@
     block-size: 3.2rem;
     padding: 0;
     border: none;
-    border-radius: fox.form(radius-xsm);
-    background: none;
-    color: fox.color(icon-neutral-default);
+    // 시안은 secondary 계열 버튼이다 — 옆의 "엑셀 다운로드"와 같은 색을 쓴다.
+    border-radius: fox.form(radius-md);
+    background: fox.color(button-secondary-surface);
+    color: fox.color(button-secondary-font);
     cursor: pointer;
 
+    &:hover:not(:disabled) {
+      background: fox.color(button-secondary-surface-hover);
+    }
+
+    &:active:not(:disabled) {
+      background: fox.color(button-secondary-surface-pressed);
+    }
+
     &:disabled {
+      background: fox.color(button-disabled-surface);
       color: fox.color(font-neutral-disabled-strong);
       cursor: default;
     }
 
@fox/styles/_fox-popover.scss (added)
+++ @fox/styles/_fox-popover.scss
@@ -0,0 +1,195 @@
+// FoxPopover — 시안: 통합관리자페이지 디자인시스템 Figma popover
+//
+// 꼬리가 달린 말풍선 판이다. 제목·본문·글자 버튼을 담고 오른쪽 위에 닫기가 붙는다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-popover fox-popover--top fox-popover--arrow-left">
+//     <div class="fox-popover__arrow-area">                  <!-- top이면 판 위, bottom이면 아래 -->
+//       <span class="fox-popover__arrow"></span>          <!-- 비어 있다. 그림은 CSS가 그린다 -->
+//     </div>
+//     <div class="fox-popover__content">
+//       <div class="fox-popover__body">
+//         <div class="fox-popover__title-row">
+//           <strong class="fox-popover__title">제목</strong>
+//         </div>
+//         <div class="fox-popover__message-row">
+//           <span class="fox-popover__message">본문</span>
+//         </div>
+//       </div>
+//       <button class="fox-text-button … fox-popover__action">바로가기</button>  <!-- 선택 -->
+//       <button class="fox-popover__close" aria-label="닫기">…X svg…</button>
+//     </div>
+//   </div>
+//
+// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다.
+// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다.
+//
+// **자리를 스스로 잡지 않는다.** 트리거 옆 어디에 놓을지는 호출부가 `className`으로 준다.
+// 자리가 모자랄 때 위아래를 뒤집는 자동 배치도 없다 — 시안이 방향을 값으로 준다.
+//
+// ⚠️ 띄우는 방식이 `fox-select-option`과 같다. 감싼 요소 기준의 일반 흐름이라
+// `overflow: hidden` 조상 안에서는 잘린다. HTML `popover` 속성으로 top layer에 올리는 방법이
+// 있지만 위치를 잡아 주는 CSS Anchor Positioning이 아직 크롬 계열에만 있어 미룬다 — 옮기게
+// 되면 셀렉트·칩·툴팁까지 한 번에 옮긴다.
+//
+// 꼬리는 **CSS가 그린다**(SVG를 쓰지 않는다). 그림과 방향·위치 규칙은 `_bubble.scss`의
+// `arrow()` 믹스인이 갖는다 — `FoxTooltipRich`와 같은 그림이라 한곳에 둔다. 왜 시안 SVG를
+// 쓰지 않는지도 그 파일 상단에 적혀 있다.
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 36rem`(360px): 시안 값 그대로다. 토큰 스케일에 360이 없다. 폭을 바꿔야
+//     하는 자리는 호출부가 `className`으로 덮는다(`fox-snackbar`·`fox-alert`와 같은 처리).
+//   - box-sizing / display / flex / position: 구조 규칙.
+//
+// 꼬리의 가로 위치는 **루트의 `align-items`**가 정한다(시안 지정). 꼬리 영역이 좌우 24px
+// 여백을 달고 다니므로, 왼쪽·오른쪽에서는 그만큼 안쪽에 서고 가운데에서는 여백이 대칭이라
+// 정확히 가운데에 선다.
+
+@use "@fox/styles/abstracts" as fox;
+@use "@fox/styles/bubble" as bubble;
+
+.fox-popover {
+  @include bubble.root;
+
+  // 시안의 neutral/lv2 — 가까운 그림자와 먼 그림자 두 겹이다.
+  //
+  // ⚠️ `box-shadow`가 아니라 `drop-shadow`다. box-shadow는 요소의 **사각형 박스**를 따라
+  // 그려지므로, 꼬리 옆 빈 자리까지 포함한 네모 그림자가 나온다(시안은 Figma 프레임이 곧
+  // 모양이라 그 차이가 없다). drop-shadow는 **실제로 칠해진 모양**을 따라가므로 판과 꼬리가
+  // 하나의 말풍선으로 그림자를 진다.
+  //
+  // 겹쳐 쓰면 뒤의 그림자가 앞 그림자까지 다시 흐리게 하지만, 두 겹 다 10% 알파라 눈에
+  // 띄는 차이는 없다.
+  filter:
+    drop-shadow(
+        #{fox.shadow(location1)} #{fox.shadow(location1)} #{fox.shadow(blur2)}
+          #{fox.primitive(alpha-black10)}
+      )
+      drop-shadow(
+        #{fox.shadow(location3)} #{fox.shadow(location3)} #{fox.shadow(blur3)}
+          #{fox.primitive(alpha-black10)}
+      );
+
+
+  // ── 판 ──────────────────────────────────────────────────────────────────────
+  &__content {
+    // 닫기 버튼의 위치 기준이다.
+    position: relative;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(4);
+    align-self: stretch;
+    padding: fox.padding(8);
+    border: fox.border(1) solid fox.color(border-neutral-strong);
+    border-radius: fox.radius(5);
+    background: fox.color(surface-neutral-default);
+  }
+
+  // ── 꼬리 ────────────────────────────────────────────────────────────────────
+  // 좌우 여백이 꼬리를 판 가장자리에서 띄운다. **늘리지 않는다** — 폭이 내용(22px + 여백
+  // 48px)만큼이라야 루트의 `align-items`가 이 덩어리를 왼쪽·가운데·오른쪽으로 옮길 수 있다.
+  //
+  // 판보다 **위에 칠해진다**(시안 구조). 꼬리가 판 쪽으로 1px 파고들고, 꼬리 안의 흰 띠가 그
+  // 자리의 판 테두리를 지워 말풍선 안팎이 하나로 이어진다. 쌓임 순서를 올리지 않으면
+  // `--top`에서 판이 나중에 칠해져 그 선이 되살아난다.
+  &__arrow-area {
+    position: relative;
+    z-index: 1;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    padding-inline: fox.padding(8);
+  }
+
+  // ── 글자 ────────────────────────────────────────────────────────────────────
+  &__body {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    align-self: stretch;
+  }
+
+  // 오른쪽 여백은 닫기 버튼 자리다 — 제목이 그 밑으로 들어가지 않게 비워 둔다.
+  &__title-row {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(5);
+    align-self: stretch;
+    padding-inline-end: fox.padding(9);
+  }
+
+  &__title {
+    flex: 1 0 0;
+    min-inline-size: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(heading-xsm);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+  }
+
+  // 제목과 본문 사이 간격을 본문의 윗여백이 맡는다(시안이 그렇게 준다) — 제목만 있을 때
+  // 아래로 빈자리가 남지 않는다.
+  &__message-row {
+    display: flex;
+    align-items: flex-start;
+    align-self: stretch;
+    padding-block-start: fox.padding(6);
+  }
+
+  &__message {
+    flex: 1 0 0;
+    min-inline-size: 0;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // 글자 버튼은 자기 모양을 그대로 갖는다 — 여기서는 줄어들지만 않게 막는다.
+  &__action {
+    flex-shrink: 0;
+  }
+
+  // ── 닫기 ────────────────────────────────────────────────────────────────────
+  // 판 오른쪽 위 모서리에 붙는다. 누르는 자리는 40px 정사각이고 아이콘은 16px이다.
+  //
+  // ⚠️ 시안이 `align-items: flex-end`를 준다 — 가로는 시작(왼쪽), 세로는 끝(아래)이라
+  // 아이콘이 40px 칸의 왼쪽 아래로 치우친다(`fox-snackbar`의 닫기와 같다).
+  &__close {
+    position: absolute;
+    inset-block-start: 0;
+    inset-inline-end: 0;
+    box-sizing: border-box;
+    display: flex;
+    align-items: flex-end;
+    inline-size: fox.number(14);
+    block-size: fox.number(14);
+    aspect-ratio: 1 / 1;
+    padding: fox.padding(2);
+    border: none;
+    background: none;
+    color: fox.color(icon-neutral-default);
+    cursor: pointer;
+  }
+
+  &__close > * {
+    flex-shrink: 0;
+    inline-size: fox.icon(3);
+    block-size: fox.icon(3);
+    aspect-ratio: 1 / 1;
+  }
+}
+
+// 꼬리. 판과 같은 색을 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다.
+@include bubble.arrow(
+  "fox-popover",
+  $border: fox.color(border-neutral-strong),
+  $fill: fox.color(surface-neutral-default)
+);
 
@fox/styles/_fox-snackbar-list.scss (added)
+++ @fox/styles/_fox-snackbar-list.scss
@@ -0,0 +1,38 @@
+// FoxSnackbarList — 시안: 통합관리자페이지 디자인시스템 Figma snackbar-list
+//
+// 스낵바가 여러 개일 때 세로로 쌓는 묶음이다. 배치만 갖는다 — 무엇이 몇 개 떠 있는지는
+// 호출부가 소유한다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-snackbar-list" role="group">
+//     …fox-snackbar…
+//     …fox-snackbar…
+//   </div>
+//
+// 자식 폭과 묶음 폭이 같은 640px이다 — 묶음이 자식을 늘이거나 줄이지 않고, `align-items`가
+// `flex-start`라 자식이 자기 폭을 그대로 갖는다(시안 지정).
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 64rem`: **시안의 폭(640px)을 그대로 적용해 둔 것이다.** 자식
+//     `fox-snackbar`와 같은 값이고 근거도 같다 — 토큰 스케일에 640이 없다. 폭을 바꿔야 하는
+//     자리는 호출부가 `className`으로 덮는다.
+//   - display / flex-direction / align-items: 구조 규칙.
+//
+// ⚠️ 모바일에서는 자식과 같은 이유로 고정 폭을 푼다 — 640px을 그대로 두면 화면 밖으로 넘쳐
+// 잘린다(`fox-snackbar` 상단 주석 참고).
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-snackbar-list {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: fox.gap(3);
+  inline-size: 64rem;
+
+  // 모바일에서는 부모 폭을 채운다. 자식(`fox-snackbar`)도 같은 규칙을 스스로 갖는다.
+  @include fox.mobile {
+    inline-size: 100%;
+  }
+}
 
@fox/styles/_fox-snackbar.scss (added)
+++ @fox/styles/_fox-snackbar.scss
@@ -0,0 +1,181 @@
+// FoxSnackbar — 시안: 통합관리자페이지 디자인시스템 Figma snackbar
+//
+// 흰 판 위에 글자(제목·본문)와 글자 버튼이 서고, 오른쪽 위에 닫기가 붙는다. 제목과 본문은
+// 각각 없을 수 있다 — 한쪽만 있으면 그 줄만 그린다. `FoxToast`와 형제지만
+// **누를 것이 있다** — 그래서 스스로 사라지지 않고 손이 닿을 때까지 남는다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-snackbar" role="status">
+//     <span class="fox-snackbar__icon">…아이콘 svg…</span>              <!-- 선택 -->
+//     <div class="fox-snackbar__content">
+//       <strong class="fox-snackbar__title">저장했습니다</strong>            <!-- 선택 -->
+//       <span class="fox-snackbar__message">3분 안에 되돌릴 수 있습니다.</span>  <!-- 선택 -->
+//     </div>
+//     <button class="fox-text-button fox-text-button--primary fox-text-button--md
+//                    fox-snackbar__action">실행 취소</button>          <!-- 선택 -->
+//     <button class="fox-snackbar__close" aria-label="닫기">…X svg…</button>  <!-- 선택 -->
+//   </div>
+//
+// 글자 버튼은 `FoxTextButton`의 primary·md를 그대로 쓴다 — 스낵바가 버튼 모양을 다시 정하지
+// 않고 `fox-snackbar__action`은 배치만 맡는다.
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size` / `min-inline-size: 64rem`: **시안의 폭(640px)을 그대로 적용해 둔
+//     것이다.** Figma가 변수로 export하지 않았고 토큰 스케일에도 640이 없다. 폭을 바꿔야 하는
+//     자리는 호출부가 `className`으로 덮는다(`fox-toggle-quantity`·`fox-alert`와 같은 처리).
+//   - ⚠️ `padding-inline-end`가 64px이라 토큰이 없다. 닫기가 `right: 0`으로 오른쪽 끝에
+//     붙으므로(시안 지정) 이 여백은 **글자·버튼이 닫기 밑으로 들어가지 않게 비워 두는
+//     자리**다. 20px + 아이콘 24px + 20px = 64px로 떨어져서 그 조합으로 적는다
+//     (`padding(7)` + `icon(5)` + `padding(7)`).
+//   - line-height / letter-spacing: 시안 텍스트 스타일 heading/xsm의 값이나 Figma가 변수로
+//     export하지 않는다. -0.375px은 15px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은
+//     근거).
+//   - box-sizing / position / display / flex: 구조 규칙.
+//
+// ⚠️ 그림자 색: 시안이 두 겹을 각각 `dark/alpha/black10`·`alpha/black10`으로 물려 놓았는데
+// 값이 같고(rgba(0,0,0,0.10)) 앞쪽 이름의 토큰은 export에 없다. 두 겹 모두 `alpha-black10`을
+// 쓴다 — `fox-toast`·`fox-select-option`과 같은 선택이다.
+//
+// ⚠️ 폭 세 값(640 / 640 / `grid(wrap-sm)`)은 **PC 기준이다.** 모바일에서 `grid(wrap-sm)`은
+// 320px인데 최소 폭 640px이 그것을 이겨 화면 밖으로 넘친다 — 실제로 잘렸다. 그래서 모바일에서는
+// 최소 폭을 풀고 판이 부모 폭을 채우게 둔다(아래 `fox.mobile` 블록).
+//
+// 시안이 모바일 스낵바의 폭을 따로 주면 그 값으로 바꾼다. 지금은 "화면을 넘지 않는다"만
+// 보장한다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-snackbar {
+  // 안쪽 여백이 바깥 폭을 늘리지 않게 한다 — 640px이 640px로 그려진다.
+  box-sizing: border-box;
+  // 닫기 버튼의 위치 기준이 된다.
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: fox.gap(5);
+  inline-size: 64rem;
+  min-inline-size: 64rem;
+  max-inline-size: fox.grid(wrap-sm);
+  padding-block: fox.padding(6);
+  padding-inline-start: fox.padding(7);
+  // 닫기 버튼이 앉을 자리(위 주석 참고).
+  padding-inline-end: calc(#{fox.padding(7)} + #{fox.icon(5)} + #{fox.padding(7)});
+  border: fox.border(1) solid fox.color(border-neutral-subtle);
+  border-radius: fox.radius(5);
+  background: fox.color(surface-neutral-default);
+
+  // 시안의 neutral/lv2 — 가까운 그림자와 먼 그림자 두 겹이다.
+  box-shadow:
+    fox.shadow(location1) fox.shadow(location1) fox.shadow(blur2) 0
+      fox.primitive(alpha-black10),
+    fox.shadow(location3) fox.shadow(location3) fox.shadow(blur3) 0
+      fox.primitive(alpha-black10);
+
+  // 모바일에서는 640px 고정을 풀고 부모 폭을 채운다(위 주석 참고). `min-inline-size`를 함께
+  // 풀지 않으면 640px이 그대로 남아 화면 밖으로 밀려 잘린다.
+  @include fox.mobile {
+    inline-size: 100%;
+    min-inline-size: 0;
+    max-inline-size: 100%;
+  }
+
+  // 글자 앞 아이콘. 글자가 길어 판이 높아져도 크기가 그대로여야 해서 줄어들지 않게 막는다.
+  //
+  // 색은 `icon-theme-primary`다(시안 확인). 글리프가 `fill: currentColor`라 슬롯에 색만
+  // 주면 그대로 입는다 — 굵기는 호출부가 넘기는 글리프가 정하고 시안은 `fill`이다.
+  //
+  // 크기는 시안 값 그대로 24px(`icon(5)`)이고 닫기 아이콘과 같다. 세로 정렬은 시안에 없어
+  // 판 규칙(가운데)을 그대로 따른다.
+  &__icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    inline-size: fox.icon(5);
+    block-size: fox.icon(5);
+    aspect-ratio: 1 / 1;
+    color: fox.color(icon-theme-primary);
+  }
+
+  // 넘긴 SVG가 슬롯을 꽉 채운다 — 크기는 슬롯이 정하고 SVG는 따라온다.
+  &__icon > * {
+    inline-size: 100%;
+    block-size: 100%;
+  }
+
+  // 남는 폭을 전부 가져가 글자 버튼을 오른쪽 끝으로 민다. `min-inline-size: 0`이 없으면
+  // 긴 글이 줄바꿈되지 않고 판을 밀어 넓힌다(flex 자식의 기본 최소 크기가 내용 크기라서다).
+  //
+  // `flex: 1 0 0`으로 남는 폭을 전부 가져가 글자 버튼을 오른쪽 끝으로 민다. 제목과 본문
+  // 사이는 `gap(1)`(2px)이고 둘 다 왼쪽에 붙는다 — 시안 확인 완료(`FoxAlert`의 같은 자리와
+  // 같은 값이다).
+  &__content {
+    display: flex;
+    flex: 1 0 0;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(1);
+    min-inline-size: 0;
+  }
+
+  &__title {
+    align-self: stretch;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(heading-xsm);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+  }
+
+  &__message {
+    align-self: stretch;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // 글자 버튼은 자기 모양을 그대로 갖는다 — 여기서는 줄어들지만 않게 막는다.
+  &__action {
+    flex-shrink: 0;
+  }
+
+  // 오른쪽 끝에 붙는다(시안 지정 `right: 0`). 흐름에서 빠져 있어 글자·버튼 배치에 끼어들지
+  // 않고, 판의 오른쪽 여백(64px)이 그 아래로 글자가 들어가지 않게 막아 준다.
+  //
+  // 누르는 자리는 48px 정사각이고 아이콘은 24px이다 — 손가락이 닿는 넓이를 아이콘보다 크게
+  // 잡은 것이라, 여백을 줄이면 모바일에서 누르기 어려워진다.
+  //
+  // ⚠️ 시안이 이 칸에 `align-items: flex-end`를 준다. 가로로는 시작(왼쪽), 세로로는 끝(아래)에
+  // 붙어서 **아이콘이 48px 칸의 왼쪽 아래로 치우친다** — 오른쪽 끝에서 16px, 아래에서 8px이다.
+  // 시안 값을 그대로 옮긴 결과이니, 화면에서 가운데로 보여야 하면 `center`로 바꾼다.
+  //
+  // 세로는 판 위쪽에 붙는다(시안 지정 `top: 0`). 가운데 정렬이 아니라서, 본문이 늘어 판이
+  // 높아져도 닫기는 첫 줄 옆에 그대로 남는다.
+  &__close {
+    position: absolute;
+    inset-inline-end: 0;
+    inset-block-start: 0;
+    display: flex;
+    align-items: flex-end;
+    inline-size: fox.number(16);
+    block-size: fox.number(16);
+    aspect-ratio: 1 / 1;
+    padding: fox.padding(3);
+    border: none;
+    background: none;
+    color: fox.color(icon-neutral-default);
+    cursor: pointer;
+  }
+
+  // 아이콘은 24px 정사각으로 못박는다 — 칸이 48px이라 늘어나게 두면 안 된다.
+  &__close > * {
+    flex-shrink: 0;
+    inline-size: fox.icon(5);
+    block-size: fox.icon(5);
+    aspect-ratio: 1 / 1;
+  }
+}
 
@fox/styles/_fox-status-indicator.scss (added)
+++ @fox/styles/_fox-status-indicator.scss
@@ -0,0 +1,88 @@
+// FoxStatusIndicator — 시안: 통합관리자페이지 디자인시스템 Figma status-indicator
+//
+// 작은 점 하나와 글자로 상태를 알린다. 크기 수식어가 없다 — 시안에 하나뿐이다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <span class="fox-status-indicator fox-status-indicator--success">
+//     <span class="fox-status-indicator__dot"></span>
+//     <span class="fox-status-indicator__label">완료</span>
+//   </span>
+//
+// 점은 비어 있는 요소다 — 배경색만으로 그린다. 뜻은 글자가 담고 점은 장식이라
+// 마크업에서 `aria-hidden`이 붙는다.
+//
+// 계열은 **점의 배경과 글자색을 각각 다른 토큰으로** 바꾼다. 점은 `element-*`(칠하는 면),
+// 글자는 `font-*`(읽는 글자)다 — 같은 계열이라도 명도가 달라 시안이 둘을 나눠 준다.
+// 기본은 점이 `element-neutral-default`(회색 면), 글자가 `font-neutral-default`다.
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일 label/md의 값이나 Figma가 변수로
+//     export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다
+//     (다른 컴포넌트와 같은 근거). line-height는 시안이 100%라 `1`이다 — 점과 글자가 한 줄에
+//     서는 조각이라 줄 간격을 두지 않는다.
+//   - display / align-items / aspect-ratio / flex-shrink: 구조 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-status-indicator {
+  display: inline-flex;
+  align-items: center;
+  gap: fox.gap(2);
+
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(label-md);
+  font-weight: fox.font-weight(regular);
+  line-height: 1;
+  letter-spacing: -0.025em;
+
+  // 6px 정원. 글자가 길어 줄이 좁아져도 찌그러지지 않게 크기를 못박는다.
+  &__dot {
+    flex-shrink: 0;
+    inline-size: fox.number(4);
+    block-size: fox.number(4);
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(max);
+    background: fox.color(element-neutral-default);
+  }
+
+  // ── 계열 ────────────────────────────────────────────────────────────────────
+  // 글자색은 블록에서 상속되므로 루트에 주고, 점은 자기 배경을 따로 받는다.
+  //
+  // `default`는 블록 규칙이 곧 그 계열의 모양이라 따로 덮을 것이 없다.
+  &--default {
+    // 시안 그대로 — 블록에 있는 값이 전부다.
+  }
+
+  &--information {
+    color: fox.color(font-system-information);
+  }
+
+  &--information &__dot {
+    background: fox.color(element-system-information-strong);
+  }
+
+  &--success {
+    color: fox.color(font-system-success);
+  }
+
+  &--success &__dot {
+    background: fox.color(element-system-success-strong);
+  }
+
+  &--warning {
+    color: fox.color(font-system-warning);
+  }
+
+  &--warning &__dot {
+    background: fox.color(element-system-warning-strong);
+  }
+
+  &--danger {
+    color: fox.color(font-system-danger);
+  }
+
+  &--danger &__dot {
+    background: fox.color(element-system-danger-strong);
+  }
+}
 
@fox/styles/_fox-step-indicator-item.scss (added)
+++ @fox/styles/_fox-step-indicator-item.scss
@@ -0,0 +1,168 @@
+// FoxStepIndicatorItem — 시안: 통합관리자페이지 디자인시스템 Figma step
+//
+// 단계 하나. 위에 동그라미와 선(인디케이터), 아래에 단계 번호와 제목이 놓인다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-step-indicator-item fox-step-indicator-item--ongoing">
+//     <div class="fox-step-indicator-item__indicator" aria-hidden="true">
+//       <span class="fox-step-indicator-item__mark"><span class="fox-step-indicator-item__dot"></span></span>
+//       <span class="fox-step-indicator-item__line"></span>                          <!-- 마지막 단계는 뺀다 -->
+//     </div>
+//     <div class="fox-step-indicator-item__text">                                    <!-- 선택 -->
+//       <span class="fox-step-indicator-item__step">STEP 01</span>
+//       <span class="fox-step-indicator-item__title">약관 동의</span>
+//     </div>
+//   </div>
+//
+// 상태별로 동그라미 안이 다르다: completion은 체크 글리프, ongoing은 `__dot`, before는 비었다.
+// 상태 클래스가 색·테두리를 정하므로 마크업은 그대로 두고 클래스만 바꾸면 된다.
+//
+// **모바일에서는 `__text`가 사라진다.** 시안의 device 변형을 CSS 브레이크포인트로 옮긴
+// 것이다 — 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고, React를 쓰지 않는
+// 소비자는 같은 결과를 얻지 못한다(`fox-chip-select-option`의 확인 버튼과 같은 근거).
+//
+// 동그라미 안쪽 여백을 시안대로 적지 않는다. 시안은 `padding: 6px 5px`로 20px 안에 10×8
+// 글리프를 밀어 넣는데, 그 값은 **가운데 정렬을 여백으로 계산한 것**이라(20-5-5=10,
+// 20-6-6=8) `justify-content`·`align-items`로 바꿔 적는다. 결과는 같고 5px·6px이라는
+// 토큰 없는 값이 사라진다.
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 6.1rem`(선 61px): 시안 값 그대로다. 토큰 스케일에 61이 없고, 동그라미
+//     (20px)와 합치면 81px이라 단계 폭(80px)보다 1px 길다 — 다음 동그라미까지 틈 없이
+//     닿게 하려고 겹치는 값으로 보인다. 단계 폭을 바꾸면 이 값도 함께 봐야 한다.
+//   - ⚠️ `block-size: 0.15rem`(선 굵기 1.5px) / `border-width: 0.15rem`(ongoing 테두리):
+//     시안 값이나 border 토큰은 1px·2px·4px뿐이라 1.5px이 없다. 토큰이 생기면 바꾼다.
+//   - ⚠️ 체크 글리프의 `10px × 8px`: 시안이 준 비율(5:4)이 다른 아이콘과 달라 `icon()` 스케일에
+//     없다. `number()`의 같은 값(10px·8px)으로 지목한다.
+//   - box-sizing / display / flex / aspect-ratio: 구조 규칙.
+//
+// ⚠️ 단계 제목의 굵기: 시안 CSS가 `var(--font-weight-regular, 700)`으로 이름과 값이
+// 어긋나는데, **값(굵게)이 맞다**(사용자 확인). 700은 이 토큰 세트에 없어서
+// (400 regular · 600 medium · 800 bold) 시스템의 `bold`를 쓴다 — Pretendard 기준으로 시안이
+// Bold(700)라면 한 단계 더 굵은 ExtraBold(800)로 그려진다. 너무 굵어 보이면 `medium`으로
+// 내린다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-step-indicator-item {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: fox.gap(3);
+  inline-size: fox.number(20);
+
+  // ── 인디케이터(동그라미 + 선) ───────────────────────────────────────────────
+  &__indicator {
+    display: flex;
+    align-items: center;
+  }
+
+  // 20px 정원. 안에 든 것(체크·점)은 상태가 정하고, 여기서는 자리만 만든다.
+  &__mark {
+    box-sizing: border-box;
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    inline-size: fox.number(9);
+    block-size: fox.number(9);
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(max);
+  }
+
+  // 체크 글리프. 크기는 슬롯이 정하고 SVG는 따라온다.
+  &__mark > svg {
+    inline-size: fox.number(6);
+    block-size: fox.number(5);
+  }
+
+  // ongoing의 가운데 점.
+  &__dot {
+    inline-size: fox.number(7);
+    block-size: fox.number(7);
+    flex-shrink: 0;
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(max);
+    background: fox.color(element-theme-primary);
+  }
+
+  &__line {
+    flex-shrink: 0;
+    inline-size: 6.1rem;
+    block-size: 0.15rem;
+  }
+
+  // ── 글자 ────────────────────────────────────────────────────────────────────
+  &__text {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    align-self: stretch;
+    padding-inline-end: fox.padding(3);
+
+    // 모바일에서는 동그라미와 선만 남는다(파일 상단 주석 참고).
+    @include fox.mobile {
+      display: none;
+    }
+  }
+
+  &__step {
+    align-self: stretch;
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-xsm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__title {
+    align-self: stretch;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    // 시안의 700은 토큰에 없어 시스템의 bold(800)로 올린다 — 파일 상단 주석 참고.
+    font-weight: fox.font-weight(bold);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // ── 상태 ────────────────────────────────────────────────────────────────────
+  // 지난 단계. 동그라미가 회색으로 채워지고 체크가 반전색으로 얹힌다. 선도 함께 진하다 —
+  // 여기까지 왔다는 표시다.
+  &--completion {
+    .fox-step-indicator-item__mark {
+      background: fox.color(element-neutral-default);
+      color: fox.color(element-neutral-inverse-static);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-default);
+    }
+  }
+
+  // 지금 단계. 테두리만 강조색이고 안은 비어 있으며, 가운데 점이 그 자리를 채운다.
+  // 선은 앞으로 갈 길이라 흐리다.
+  &--ongoing {
+    .fox-step-indicator-item__mark {
+      border: 0.15rem solid fox.color(element-theme-primary);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-disabled);
+    }
+  }
+
+  // 아직 오지 않은 단계. 흐린 테두리에 연한 면이고 안은 비었다.
+  &--before {
+    .fox-step-indicator-item__mark {
+      border: fox.border(1) solid fox.color(element-neutral-disabled);
+      background: fox.color(element-neutral-subtle);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-disabled);
+    }
+  }
+}
 
@fox/styles/_fox-step-indicator.scss (added)
+++ @fox/styles/_fox-step-indicator.scss
@@ -0,0 +1,34 @@
+// FoxStepIndicator — 시안: 통합관리자페이지 디자인시스템 Figma step-indicator
+//
+// `fox-step-indicator-item`들을 가로로 늘어놓는 묶음이다. 배치만 갖는다 — 어디까지 왔는지는 각 단계가 말한다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-step-indicator" role="group" aria-label="가입 절차">
+//     …fox-step-indicator-item (completion)…
+//     …fox-step-indicator-item (ongoing)…
+//     …fox-step-indicator-item (before, 마지막이면 선 없이)…
+//   </div>
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 40rem`(400px): 시안 값 그대로다. 80px × 5단계라 **단계가 다섯 개일 때의
+//     폭**이고, 개수가 다르면 맞지 않는다. 토큰 스케일에도 400이 없다. 단계 수가 다른 화면은
+//     호출부가 `className`으로 덮는다 — 시안에서 폭 규칙을 받으면 그 값으로 바꾼다.
+//   - display / align-items: 구조 규칙.
+//
+// 세로 정렬이 `center`라 글자가 있는 화면과 없는 화면(모바일)에서 동그라미 줄이 같은 높이에
+// 놓인다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-step-indicator {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  inline-size: 40rem;
+
+  // 모바일에서는 글자가 사라져 폭이 남는다 — 고정 폭을 풀어 화면에 맞춘다
+  // (`fox-step-indicator-item`의 `__text`가 숨는 것과 짝이다).
+  @include fox.mobile {
+    inline-size: 100%;
+  }
+}
@fox/styles/_fox-tab-item.scss
--- @fox/styles/_fox-tab-item.scss
+++ @fox/styles/_fox-tab-item.scss
@@ -10,8 +10,9 @@
 //   - line 고름: 흰 바탕 + 4px 밑줄 + 파란 글자
 //   - fill 고름: 남색 바탕 + 흰 글자
 //
-// line 계열의 기본 상태도 밑줄 자리를 1px 비워 둔다 — 고름으로 바뀔 때 글자가 밀리지
-// 않게 하려면 자리를 미리 잡아야 한다(시안도 투명 1px을 둔다).
+// line 계열의 밑줄은 `border`가 아니라 안쪽 그림자로 그린다. Figma는 stroke를 상자 **안쪽**에
+// 그려 4px 밑줄이 높이를 늘리지 않지만(시안의 tab-item은 고름 여부와 무관하게 60px), CSS
+// `border`는 상자를 키운다 — border로 그리면 고른 탭만 4px 커지고 옆 탭과 3px 어긋난다.
 //
 // 토큰이 아닌 값:
 //   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
@@ -69,13 +70,9 @@
 }
 
 // ── line 계열 ───────────────────────────────────────────────────────────────
-// 고름으로 바뀔 때 글자가 밀리지 않게 밑줄 자리를 미리 잡는다.
-.fox-tab--line .fox-tab-item {
-  border-block-end: fox.border(1) solid fox.color(action-secondary);
-}
-
+// 밑줄은 상자 안쪽에 그린다(위 주석 참조) — 높이가 60px로 고정돼 고름 여부에 글자가 밀리지 않는다.
 .fox-tab--line .fox-tab-item--active {
-  border-block-end: fox.border(3) solid fox.color(action-secondary-active);
+  box-shadow: inset 0 calc(-1 * #{fox.border(3)}) 0 0 fox.color(action-secondary-active);
   background: fox.color(surface-neutral-default);
   color: fox.color(font-theme-secondary);
 }
 
@fox/styles/_fox-toast.scss (added)
+++ @fox/styles/_fox-toast.scss
@@ -0,0 +1,53 @@
+// FoxToast — 시안: 통합관리자페이지 디자인시스템 Figma toast
+//
+// 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-toast" role="status">저장되었습니다.</div>
+//
+// 글자를 감싸는 엘리먼트를 두지 않는다 — 안에 들어가는 것이 글자 하나뿐이라 따로 규칙을
+// 걸 대상이 없다. 아이콘이나 버튼이 붙는 날이 오면 그때 `__label`을 만든다.
+//
+// **띄우는 위치는 여기서 정하지 않는다.** 시안이 조각의 모양만 주고, 화면 어디에 어떻게
+// 나타나는지는 쓰는 화면이 정한다(사용자 결정). 호출부가 `className`으로 `position: fixed`
+// 같은 배치를 준다.
+//
+// 배경·글자색이 `*-inverse` 토큰이다 — 라이트에서 어두운 판 + 흰 글자이고, 다크 모드에서는
+// 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 된다. 두 값을 한 쌍으로 골라야 대비가 유지된다.
+//
+// 토큰이 아닌 값(FoxButton·FoxTag와 동일한 근거로 승인된 항목):
+//   - line-height / letter-spacing: 시안 텍스트 스타일 body/md의 값이나 Figma가 변수로
+//     export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다 — px으로
+//     박으면 글자 크기가 모바일에서 13px으로 줄 때 비율이 어긋난다.
+//   - box-sizing / display / align-items: 구조 규칙.
+//
+// ⚠️ 그림자 색: 시안이 두 겹을 각각 `dark/alpha/black10`·`alpha/black10`으로 물려 놓았는데
+// 값이 같고(rgba(0,0,0,0.10)) 앞쪽 이름의 토큰은 export에 없다. 그래서 두 겹 모두
+// `alpha-black10`을 쓴다 — `fox-select-option`과 같은 선택이다(다크 모드에서도 검은 그림자).
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-toast {
+  // 안쪽 여백이 바깥 크기를 늘리지 않게 한다.
+  box-sizing: border-box;
+  display: inline-flex;
+  // 글자 하나뿐이라 지금은 티가 나지 않지만, 시안이 세로 가운데 정렬을 명시한다.
+  align-items: center;
+  padding: fox.padding(5) fox.padding(7);
+  border-radius: fox.radius(4);
+  background: fox.color(surface-neutral-default-inverse);
+
+  // 시안의 neutral/lv3 — 가까운 그림자와 먼 그림자 두 겹이다.
+  box-shadow:
+    fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0
+      fox.primitive(alpha-black10),
+    fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0
+      fox.primitive(alpha-black10);
+
+  color: fox.color(font-neutral-strong-inverse);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-md);
+  font-weight: fox.font-weight(regular);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+}
 
@fox/styles/_fox-tooltip-rich.scss (added)
+++ @fox/styles/_fox-tooltip-rich.scss
@@ -0,0 +1,120 @@
+// FoxTooltipRich — 시안: 통합관리자페이지 디자인시스템 Figma tooltip-rich
+//
+// 제목과 본문을 담은 말풍선이다. `fox-popover`와 판·꼬리의 구조가 같고 **누를 것이 없다** —
+// 닫기도 액션 버튼도 없다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-tooltip-rich fox-tooltip-rich--top fox-tooltip-rich--arrow-left">
+//     <div class="fox-tooltip-rich__arrow-area">        <!-- top이면 판 위, bottom이면 아래 -->
+//       <span class="fox-tooltip-rich__arrow"></span>   <!-- 비어 있다. 그림은 CSS가 그린다 -->
+//     </div>
+//     <div class="fox-tooltip-rich__content">
+//       <div class="fox-tooltip-rich__body">
+//         <div class="fox-tooltip-rich__title-row">
+//           <strong class="fox-tooltip-rich__title">제목</strong>
+//         </div>
+//         <div class="fox-tooltip-rich__message-row">
+//           <span class="fox-tooltip-rich__message">본문</span>
+//         </div>
+//       </div>
+//     </div>
+//   </div>
+//
+// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다.
+// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다.
+//
+// 꼬리는 **CSS가 그린다**(SVG를 쓰지 않는다). 그림과 방향·위치 규칙은 `_bubble.scss`의
+// `arrow()` 믹스인이 갖는다 — `fox-popover`와 같은 그림이라 한곳에 둔다. 왜 시안 SVG를
+// 쓰지 않는지도 그 파일 상단에 적혀 있다.
+//
+// `fox-popover`와 다른 점은 셋뿐이다:
+//   1. 테두리가 한 단계 옅다(`border-neutral-default` ↔ 팝오버의 `border-neutral-strong`).
+//      꼬리도 같은 값을 받는다.
+//   2. 제목과 본문 사이가 좁다(`padding-3` 8px ↔ 팝오버의 `padding-6` 16px).
+//   3. 그림자가 없다 — 시안이 주지 않는다.
+//
+// 제목 줄의 오른쪽 여백(`padding-9`)은 팝오버와 같다. 닫기 버튼이 없어 비워 둘 이유가
+// 없어 보이지만 시안이 같은 값을 주므로 그대로 둔다 — 두 말풍선의 글줄 시작·끝이 맞는다.
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 36rem`(360px): 시안 값 그대로다. 토큰 스케일에 360이 없다. 폭을 바꿔야
+//     하는 자리는 호출부가 `className`으로 덮는다(`fox-popover`와 같은 처리).
+//   - line-height / letter-spacing: 시안 텍스트 스타일 heading/xsm·body/sm의 값이나 Figma가
+//     변수로 export하지 않는다. -0.375px·-0.325px은 각각 15px·13px 기준이라 `-0.025em`으로
+//     적는다(다른 컴포넌트와 같은 근거).
+//   - box-sizing / display / flex: 구조 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+@use "@fox/styles/bubble" as bubble;
+
+.fox-tooltip-rich {
+  @include bubble.root;
+
+  // ── 판 ──────────────────────────────────────────────────────────────────────
+  &__content {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(4);
+    align-self: stretch;
+    padding: fox.padding(8);
+    border: fox.border(1) solid fox.color(border-neutral-default);
+    border-radius: fox.radius(5);
+    background: fox.color(surface-neutral-default);
+  }
+
+  // ── 글자 ────────────────────────────────────────────────────────────────────
+  &__body {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    align-self: stretch;
+  }
+
+  &__title-row {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(5);
+    align-self: stretch;
+    padding-inline-end: fox.padding(9);
+  }
+
+  &__title {
+    flex: 1 0 0;
+    min-inline-size: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(heading-xsm);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.4;
+    letter-spacing: -0.025em;
+  }
+
+  // 제목과 본문 사이 간격을 본문의 윗여백이 맡는다(시안이 그렇게 준다) — 제목만 있을 때
+  // 아래로 빈자리가 남지 않는다.
+  &__message-row {
+    display: flex;
+    align-items: flex-start;
+    align-self: stretch;
+    padding-block-start: fox.padding(3);
+  }
+
+  &__message {
+    flex: 1 0 0;
+    min-inline-size: 0;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+}
+
+// 꼬리. 판과 같은 색을 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다.
+@include bubble.arrow(
+  "fox-tooltip-rich",
+  $border: fox.color(border-neutral-default),
+  $fill: fox.color(surface-neutral-default)
+);
 
@fox/styles/_fox-tooltip.scss (added)
+++ @fox/styles/_fox-tooltip.scss
@@ -0,0 +1,110 @@
+// FoxTooltip — 시안: 통합관리자페이지 디자인시스템 Figma tooltip
+//
+// 어두운 판에 짧은 글 한 줄을 담은 말풍선이다. `fox-tooltip-rich`의 작은 형제로, 제목이 없고
+// 꼬리가 언제나 가운데이며 판이 글자만큼만 넓어진다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-tooltip fox-tooltip--top">
+//     <div class="fox-tooltip__arrow-area">          <!-- top이면 판 위, bottom이면 아래 -->
+//       <span class="fox-tooltip__arrow">…꼬리 svg…</span>
+//     </div>
+//     <div class="fox-tooltip__content">글자</div>
+//   </div>
+//
+// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다.
+// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다.
+//
+// 꼬리의 가로 위치를 고르는 수식어가 없다 — 시안이 언제나 가운데에 둔다. 그래서 이 파일은
+// `_bubble.scss`의 `arrow()` 믹스인을 쓰지 않는다. 저쪽은 **테두리가 있는 판**의 이음새를
+// 지우려고 회전 정사각형을 반쯤 걸치는 방식인데, 이 판은 테두리 없이 한 색이라 그럴 일이 없고
+// 시안의 꼬리도 12×6에 끝이 둥근 다른 그림이다.
+//
+// 판은 `inline-flex`라 글자만큼만 넓어진다. 다만 꼬리 영역이 좌우 24px 여백을 달고 있어
+// **가장 좁을 때가 60px**(12 + 48)이다 — 글자가 그보다 짧으면 판보다 꼬리 영역이 넓어져
+// 말풍선 전체 폭을 그 값이 정한다(시안이 그렇게 준다).
+//
+// 토큰이 아닌 값:
+//   - ⚠️ 꼬리 12×6px: 시안이 준 그림의 크기이고 토큰 스케일에 없다.
+//   - line-height / letter-spacing: 시안 텍스트 스타일 body/sm의 값이나 Figma가 변수로
+//     export하지 않는다. -0.325px은 13px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은
+//     근거).
+//   - display / flex: 구조 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-tooltip {
+  box-sizing: border-box;
+  display: inline-flex;
+  flex-direction: column;
+  align-items: center;
+  // 글자만큼 넓어진다. `inline-flex`만으로는 부족하다 — 툴팁은 대개 트리거 옆에
+  // `position: absolute`로 떠 있고, 그때 폭은 **기준이 되는 조상의 폭**을 한도로 계산된다.
+  // 트리거가 24px짜리면 툴팁도 그 안에 맞추려 글자를 쪼갠다. `max-content`는 그 한도를 벗어나
+  // 내용 그대로의 폭을 갖게 한다.
+  //
+  // ⚠️ 글이 길면 그만큼 한 줄로 길어진다. 줄바꿈이 필요한 자리는 호출부가 `className`으로
+  // `max-inline-size`를 준다 — 애초에 긴 글은 `fox-tooltip-rich`가 맡는다.
+  inline-size: max-content;
+
+  &__content {
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: fox.padding(2) fox.padding(5);
+    border-radius: fox.radius(2);
+    background: fox.color(surface-neutral-default-inverse);
+
+    color: fox.color(font-neutral-strong-inverse);
+    text-align: center;
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__arrow-area {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding-inline: fox.padding(8);
+  }
+
+  // 꼬리 색은 판과 같은 값이다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다. SVG가
+  // `fill="currentColor"`라 이 색을 그대로 입는다.
+  &__arrow {
+    display: block;
+    inline-size: 1.2rem;
+    block-size: 0.6rem;
+    color: fox.color(surface-neutral-default-inverse);
+  }
+
+  &__arrow > svg {
+    display: block;
+    inline-size: 100%;
+    block-size: 100%;
+  }
+
+  // 꼬리가 판 위에 붙으면 뾰족한 끝이 위를 향해야 한다. 시안 그림은 아래를 향하고 있어
+  // 뒤집는다 — 같은 그림 하나로 두 방향을 만든다.
+  &--top &__arrow > svg {
+    transform: rotate(180deg);
+  }
+
+  // 꼬리를 판 쪽으로 0.5px 겹친다.
+  //
+  // ⚠️ 시안에 없는 값이다. 딱 붙여만 두면 소수 배율 화면(윈도우 125% 등)에서 두 요소의 경계가
+  // 물리 픽셀 중간에 떨어져 **그 사이로 밝은 실선이 비친다.** 판과 꼬리가 같은 색이라 겹쳐도
+  // 보이는 모양은 그대로이고, 0.5px이면 꼬리 높이(6px)가 눈에 띄게 줄지도 않는다.
+  // (테두리가 있는 `fox-popover`·`fox-tooltip-rich`는 같은 문제를 다른 방식으로 푼다 —
+  // `_bubble.scss` 상단 참고.)
+  &--top &__arrow-area {
+    margin-block-end: -0.05rem;
+  }
+
+  &--bottom &__arrow-area {
+    margin-block-start: -0.05rem;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -8,6 +8,7 @@
 @use "fox-button-group";
 @use "fox-button-panel";
 @use "fox-address";
+@use "fox-alert";
 @use "fox-certified";
 @use "fox-check-icon";
 @use "fox-checkbox";
@@ -16,11 +17,15 @@
 @use "fox-chip-area";
 @use "fox-chip-select-option";
 @use "fox-conditional-option";
+@use "fox-contextual-trigger";
 @use "fox-email";
 @use "fox-file-upload";
 @use "fox-form-label";
+@use "fox-heading-group";
+@use "fox-helper-text";
 @use "fox-input";
 @use "fox-phone-number";
+@use "fox-popover";
 @use "fox-radio";
 @use "fox-radio-group";
 @use "fox-radio-icon";
@@ -49,7 +54,15 @@
 @use "fox-table-cell";
 @use "fox-tab";
 @use "fox-tab-item";
+@use "fox-snackbar";
+@use "fox-snackbar-list";
 @use "fox-spinner";
+@use "fox-status-indicator";
+@use "fox-step-indicator-item";
+@use "fox-step-indicator";
+@use "fox-toast";
+@use "fox-tooltip";
+@use "fox-tooltip-rich";
 @use "fox-toggle-quantity";
 @use "fox-page-header";
 @use "fox-modal";
app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
--- app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
+++ app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
@@ -13,6 +13,7 @@
   FoxSquaresFourIcon,
   FoxUserSquareIcon,
   FoxUsersThreeIcon,
+  FoxWrenchIcon,
   type FoxIconProps,
 } from '@fox/core/icons';
 
@@ -38,6 +39,7 @@
   'shield-check': FoxShieldCheckIcon,
   'phone-call': FoxPhoneCallIcon,
   'chart-bar': FoxChartBarIcon,
+  wrench: FoxWrenchIcon,
 };
 
 /** 모르는 이름이 왔을 때. 메뉴가 통째로 사라지는 것보다 낫다. */
app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
@@ -21,11 +21,13 @@
 import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
+import { DEFAULT_MOBILE_PHONE_PREFIX } from '@/lib/domain/phone-number';
 import {
   ADMIN_EMAIL_MESSAGES,
   ADMIN_LOGIN_ID_HELP_TEXT,
   ADMIN_LOGIN_ID_MESSAGES,
   ADMIN_LOGIN_ID_VALIDATORS,
+  ADMIN_PASSWORD_ERROR_TEXT,
   ADMIN_PASSWORD_HELP_TEXT,
   ADMIN_PASSWORD_POLICY,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
@@ -90,7 +92,8 @@
   });
   const [isChecking, startChecking] = useTransition();
 
-  const [phoneNumber, setPhoneNumber] = useState('');
+  // 앞자리는 고르지 않아도 되도록 기본값으로 시작한다(상수 주석 참조).
+  const [phoneNumber, setPhoneNumber] = useState(DEFAULT_MOBILE_PHONE_PREFIX);
   const [emailLocal, setEmailLocal] = useState('');
   const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]);
   const [isCustomDomain, setIsCustomDomain] = useState(false);
@@ -125,17 +128,19 @@
         )
       : undefined);
 
-  // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
+  // 중복 확인 결과는 화면 규칙보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
   // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다.
   const loginIdFailure =
     checkResult.status === 'unavailable' || checkResult.status === 'failed'
       ? checkResult.message
       : undefined;
+  // **서버 오류는 성공 문구를 이긴다** — 확인을 통과한 뒤에도 저장이 ID 때문에 거부될 수 있는데
+  // (확인과 저장 사이의 선점, 형식 규칙 변경), '사용할 수 있는 ID입니다.'가 계속 떠 있으면
+  // 거부된 사실이 화면에서 사라진다.
   const loginIdMessage =
     loginIdFailure ??
-    (isLoginIdVerified
-      ? '사용할 수 있는 ID입니다.'
-      : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT));
+    errors.loginId ??
+    (isLoginIdVerified ? '사용할 수 있는 ID입니다.' : ADMIN_LOGIN_ID_HELP_TEXT);
 
   return (
     <FoxModal
@@ -222,7 +227,7 @@
           invalid={Boolean(errors.password)}
           // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다.
           validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
-          validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
+          validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
         />
 
         <AdminMemberFormFields
app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
@@ -13,6 +13,7 @@
 import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
 import {
   ADMIN_EMAIL_MESSAGES,
+  ADMIN_PASSWORD_ERROR_TEXT,
   ADMIN_PASSWORD_HELP_TEXT,
   ADMIN_PASSWORD_POLICY,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
@@ -23,6 +24,7 @@
 import { AdminMemberFormFields } from './admin-member-form-fields';
 import styles from './admin-member-modal.module.scss';
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { DEFAULT_MOBILE_PHONE_PREFIX } from '@/lib/domain/phone-number';
 
 interface AdminMemberEditModalProps {
   member: AdminMember;
@@ -62,8 +64,9 @@
   const formRef = useRef<HTMLFormElement>(null);
 
   const [isPasswordEditable, setIsPasswordEditable] = useState(false);
+  // 저장된 번호가 없으면 등록 팝업과 같이 기본 앞자리로 시작한다.
   const [phoneNumber, setPhoneNumber] = useState(
-    toPhoneDigits(member.phoneNumber)
+    () => toPhoneDigits(member.phoneNumber) || DEFAULT_MOBILE_PHONE_PREFIX
   );
   const [email, setEmail] = useState(member.email ?? '');
   const [roleCode, setRoleCode] = useState(
@@ -140,7 +143,7 @@
             invalid={Boolean(errors.password)}
             // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점).
             validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
-            validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
+            validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
           />
           <FoxButton
             type="secondary"
app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
@@ -11,6 +11,7 @@
   ADMIN_ROLE_OPTIONS,
 } from '@/lib/domain/admin-member';
 import type { AdminMemberFormErrors } from '@/lib/domain/admin-member-form';
+import { MOBILE_PHONE_PREFIXES } from '@/lib/domain/phone-number';
 import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberFormFieldsProps {
@@ -78,6 +79,8 @@
         </FoxFormLabel>
         <FoxPhoneNumber
           type="unit"
+          // 넘기지 않으면 앞자리 셀렉트가 빈 목록이라 아무것도 고를 수 없다(상수 주석 참조).
+          prefixOptions={[...MOBILE_PHONE_PREFIXES]}
           value={phoneNumber}
           onChange={onPhoneNumberChange}
           message={errors.phoneNumber}
app/(protected)/(basic)/decoration-items/_actions.ts
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
@@ -2,12 +2,14 @@
 
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import {
   createDecorationItem,
   deleteDecorationItem,
   updateDecorationItem,
   uploadDecorationItemImage,
 } from '@/lib/data/repositories/decoration-item-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
 import type { DecorationItemType } from '@/lib/domain/decoration-item';
 import {
   validateDecorationItemCreate,
@@ -24,7 +26,8 @@
  * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §3 SRP 체크).
  *
  * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/decoration-item-form.ts`의 규칙을 호출) —
- * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다.
+ * 화면의 required 속성·기본값 채움은 편의일 뿐 신뢰 경계가 아니다. **허용 카테고리도 여기서
+ * 다시 조회해 넘긴다** — 화면이 보낸 코드를 그대로 믿으면 코드테이블에 없는 값이 저장된다.
  *
  * **이미지 업로드도 이 계층을 지난다** — 브라우저는 백엔드를 직접 호출하지 않으므로(토큰이
  * httpOnly 세션 안에만 있다) 폼이 실어 보낸 File을 여기서 받아 Repository를 통해 백엔드에
@@ -119,7 +122,8 @@
     readEditableValues(
       formData,
       newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId
-    )
+    ),
+    await fetchCommonCodes(CODE_GROUP.decorationItemCategory)
   );
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
@@ -162,7 +166,8 @@
     readEditableValues(
       formData,
       newFile ? PENDING_UPLOAD_PLACEHOLDER : currentImageFileId
-    )
+    ),
+    await fetchCommonCodes(CODE_GROUP.decorationItemCategory)
   );
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
@@ -1,11 +1,12 @@
 'use client';
 
-import { useActionState, useEffect } from 'react';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Modal } from '@/components/ui/modal';
+import { useActionState, useEffect, useRef } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { DecorationItemType } from '@/lib/domain/decoration-item';
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
 import { createDecorationItemAction } from '../_actions';
@@ -16,27 +17,30 @@
   /** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */
   defaultItemType: DecorationItemType;
   typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */
+  categories: CommonCode[];
   onClose: () => void;
 }
 
-const FORM_ID = 'decoration-item-create-form';
-
 /**
- * 꾸미기 아이템 등록 팝업(시안 ADM_ITM_102_p).
+ * 꾸미기 아이템 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_102_p (5227:4629)
  *
- * 아이템ID는 입력 항목이 아니다 — 시안대로 "등록 후 자동발급됩니다."를 읽기 전용으로 보여주고,
- * 실제 값은 백엔드가 `itemSn`(자동증가)으로 채운다.
+ * **아이템ID는 입력받지 않는다.** 시안은 직접 입력하는 칸으로 그리지만 백엔드 `TB_COM_ITEM`에
+ * 그런 컬럼이 없고 쓰기 API도 그 값을 받지 않는다 — 자동증가 PK인 `itemSn`이 목록의 아이템ID로
+ * 그대로 노출되는 값이라, 등록 시점에는 발급 전이므로 읽기 전용 안내로 둔다(백엔드에 컬럼이
+ * 생기면 이 칸만 입력으로 바꾸면 된다).
  *
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다(admins의 등록 팝업과
- * 동일한 패턴) — 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그
- * form을 제출한다.
+ * 등록 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — `FoxButton`에 폼을 가리키는 prop이 없어
+ * ref로 직접 제출한다(학생 회원 조회 팝업과 같은 방식).
  */
 export function DecorationItemCreateModal({
   defaultItemType,
   typeTotalCount,
+  categories,
   onClose,
 }: DecorationItemCreateModalProps) {
   const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
     createDecorationItemAction,
     INITIAL_DECORATION_ITEM_FORM_STATE
@@ -52,44 +56,45 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="sm"
       title="꾸미기 아이템 등록"
       onClose={onClose}
-      footer={
+      actions={
         <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '등록 중...' : '등록'}
             disabled={isPending}
-          >
-            {isPending ? '등록 중...' : '등록'}
-          </Button>
+            onAction={() => formRef.current?.requestSubmit()}
+          />
         </>
       }
     >
-      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
-
-        <Field label="아이템 ID">
-          <Input type="text" value="등록 후 자동발급됩니다." readOnly />
-        </Field>
-
+      <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)}>
         <DecorationItemFormFields
           defaultItemType={defaultItemType}
           typeTotalCount={typeTotalCount}
+          categories={categories}
           errors={errors}
+          itemIdField={
+            <FoxInput
+              size="md"
+              label="아이템 ID"
+              requirement="required"
+              value="등록 후 자동발급됩니다."
+              readOnly
+            />
+          }
         />
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <FoxHelperText type="danger" message={state.message} />
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
@@ -1,11 +1,12 @@
 'use client';
 
-import { useActionState, useEffect } from 'react';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Modal } from '@/components/ui/modal';
+import { useActionState, useEffect, useRef } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { DecorationItem } from '@/lib/domain/decoration-item';
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
 import { updateDecorationItemAction } from '../_actions';
@@ -15,26 +16,27 @@
 interface DecorationItemEditModalProps {
   item: DecorationItem;
   typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. */
+  categories: CommonCode[];
   onClose: () => void;
 }
 
-const FORM_ID = 'decoration-item-edit-form';
-
 /**
- * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록과 동일 항목이되 **아이템ID는 읽기
- * 전용**이다.
+ * 꾸미기 아이템 수정 팝업(시안 ADM_ITM_103_p) — 등록 팝업(5227:4629)과 같은 구성이되
+ * **아이템ID는 읽기 전용**이다.
  *
- * readOnly로 보여주는 아이템ID `Input`에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고,
- * 실제 수정 대상은 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만
- * 읽으므로 readOnly 필드를 위조해서 보내도 수정 대상이 바뀌지 않는다(admins의 이름/ID readOnly
- * 처리와 동일한 방어 방식).
+ * 읽기 전용 아이템ID 칸에는 `name`을 주지 않아 제출 대상에서 아예 빠지게 하고, 실제 수정 대상은
+ * 별도 hidden input(`itemSn`)으로 넘긴다 — Server Action도 hidden 값만 읽으므로 읽기 전용 칸을
+ * 위조해 보내도 수정 대상이 바뀌지 않는다.
  */
 export function DecorationItemEditModal({
   item,
   typeTotalCount,
+  categories,
   onClose,
 }: DecorationItemEditModalProps) {
   const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
     updateDecorationItemAction,
     INITIAL_DECORATION_ITEM_FORM_STATE
@@ -50,47 +52,48 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="sm"
       title="꾸미기 아이템 수정"
       onClose={onClose}
-      footer={
+      actions={
         <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '수정 중...' : '수정'}
             disabled={isPending}
-          >
-            {isPending ? '수정 중...' : '수정'}
-          </Button>
+            onAction={() => formRef.current?.requestSubmit()}
+          />
         </>
       }
     >
-      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
+      <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)}>
         <input type="hidden" name="itemSn" value={item.itemSn} />
-
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
-
-        <Field label="아이템 ID">
-          <Input type="text" value={item.itemSn} readOnly />
-        </Field>
 
         <DecorationItemFormFields
           item={item}
           defaultItemType={item.itemType}
           typeTotalCount={typeTotalCount}
+          categories={categories}
           errors={errors}
+          itemIdField={
+            <FoxInput
+              size="md"
+              label="아이템 ID"
+              requirement="required"
+              value={String(item.itemSn)}
+              readOnly
+            />
+          }
         />
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <FoxHelperText type="danger" message={state.message} />
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.module.scss (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.module.scss
@@ -0,0 +1,20 @@
+// 등록·수정 팝업의 form-list — 시안 ADM_ITM_102_p(5227:4629)의 `item` 한 줄 배치.
+// 항목마다 위아래 16px 여백과 아래 실선 하나를 갖고, 라벨과 입력 사이는 8px이다.
+// @fox에 폼 줄 배치 조각이 없어 이 화면 전용으로 둔다(값은 전부 @fox 토큰).
+
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+.field {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block: fox.padding(6);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
@@ -1,159 +1,219 @@
 'use client';
 
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { RadioGroup } from '@/components/ui/radio-group';
-import { Select } from '@/components/ui/select';
+import { useState, type ReactNode } from 'react';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxTab } from '@fox/core/components/fox-tab';
+import { FoxTabItem } from '@fox/core/components/fox-tab-item';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import type { CommonCode } from '@/lib/domain/common-code';
 import {
-  DECORATION_ITEM_CATEGORIES,
   DECORATION_ITEM_TYPE_OPTIONS,
-  DEFAULT_DECORATION_ITEM_CATEGORY_CODE,
+  formatDecorationItemActiveLabel,
   type DecorationItem,
   type DecorationItemType,
 } from '@/lib/domain/decoration-item';
 import type { DecorationItemFormErrors } from '@/lib/domain/decoration-item-form';
-
-const ACTIVE_STATUS_OPTIONS = [
-  { value: 'true', label: '사용' },
-  { value: 'false', label: '미사용' },
-];
+import styles from './decoration-item-form-fields.module.scss';
 
 interface DecorationItemFormFieldsProps {
   /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼 + 기본값). */
   item?: DecorationItem;
   /** 등록 팝업을 연 시점의 활성 탭(유형) — 수정 팝업에서는 `item.itemType`이 대신 쓰인다. */
   defaultItemType: DecorationItemType;
-  /** "정렬순서" 라벨의 "(총 등록 N개)" 힌트와 등록 시 기본값 계산에 쓰는, 현재 유형의 전체
-   *  등록 건수(검색어 무관). */
+  /** 현재 유형의 전체 등록 건수(검색어 무관) — 정렬순서의 "/ 총 N개"와 기본값에 쓴다. */
   typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 카테고리 선택지. 서버에서 조회해 내려온다. */
+  categories: CommonCode[];
   errors: DecorationItemFormErrors;
+  /** 아이템ID 줄 — 등록은 자동발급 안내, 수정은 발급된 값이라 팝업마다 달라 밖에서 받는다. */
+  itemIdField: ReactNode;
 }
 
+/** 시안의 안내 문구 그대로. */
+const IMAGE_DESCRIPTION =
+  'JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)';
+
 /**
- * 등록·수정 팝업이 공유하는 입력 항목 — 유형/아이템명/카테고리/포인트/썸네일 이미지/설명/
- * 사용여부/정렬순서(시안 ADM_ITM_102_p / 103_p). 아이템ID 표시만 각 팝업이 따로 그린다.
+ * 등록·수정 팝업이 공유하는 입력 항목 — 시안 ADM_ITM_102_p(5227:4629).
  *
- * **유형(개별/셋트) 전환 시 폼 구성이 바뀔 수 있다는 시안 설명이 있지만, 두 유형의 입력 항목이
- * 완전히 같아 조건부 렌더링을 두지 않았다** — 유형 값 자체만 폼에 실어 저장한다.
+ * 유형은 라디오가 아니라 **탭**이다(시안). 목록의 유형 탭과 달리 이건 화면 이동이 아니라 폼의
+ * 값이라 상태를 여기서 들고, 저장에는 hidden 필드로 실어 보낸다 — `FoxTab`은 폼 요소가 아니다.
+ * 카테고리(`FoxSelect`)도 네이티브 `<select>`가 아니라 버튼+리스트박스라 같은 방식이다.
  *
- * **이미지는 파일 하나만 받는다** — 시안의 "썸네일, 아이템 이미지 각각 등록? → NO"에 따른 것이고,
- * 저장은 백엔드의 `atchFileId`에만 한다(썸네일은 추후 백엔드가 자동생성할 예정, 사용자 확정 사항).
- * 수정 팝업에서 파일을 새로 고르지 않으면 hidden 필드로 유지한 기존 파일 ID가 그대로 저장된다.
+ * **두 유형의 입력 항목은 완전히 같다** — 시안이 "셋트아이템도 동일한 항목으로 처리"라고
+ * 명시해 탭은 저장될 유형 값만 바꾼다.
  *
- * 설명은 여러 줄 입력이 자연스럽지만 공용 textarea 컴포넌트가 없어 로직 우선 단계에서는 단일행
- * `Input`으로 둔다(신설은 design 레인 소관).
+ * 썸네일은 파일 하나만 받는다. `FoxFileUpload`의 네이티브 입력에 `name`을 주면 고른 파일이
+ * 폼 제출에 그대로 실리므로 파일을 따로 들고 있지 않는다. 수정 팝업에서 새로 고르지 않으면
+ * hidden 필드의 기존 파일 ID가 그대로 저장된다.
  */
 export function DecorationItemFormFields({
   item,
   defaultItemType,
   typeTotalCount,
+  categories,
   errors,
+  itemIdField,
 }: DecorationItemFormFieldsProps) {
+  const [itemType, setItemType] = useState<DecorationItemType>(
+    item?.itemType ?? defaultItemType
+  );
+  // 수정 팝업은 기존 값, 등록 팝업은 첫 코드가 기본이다. 코드가 하나도 없으면 빈 값이고,
+  // 그대로 저장하면 Server Action의 검증이 "카테고리를 선택해 주세요."로 잡는다.
+  const [categoryCode, setCategoryCode] = useState(
+    item?.categoryCode || categories[0]?.code || ''
+  );
+  const [isActive, setIsActive] = useState(item?.isActive ?? true);
+  // 수정 팝업에서 파일을 새로 고르지 않았을 때 유지할 기존 이미지. 썸네일을 지우면 함께 비워
+  // 검증이 "썸네일 이미지를 등록해 주세요."로 걸리게 한다 — 비우지 않으면 지운 것처럼 보이는데
+  // 저장은 예전 이미지를 그대로 남긴다.
+  const [retainedImageFileId, setRetainedImageFileId] = useState(
+    item?.imageFileId ?? ''
+  );
+
   return (
     <>
-      <Field label="유형 *">
-        <RadioGroup
-          name="itemType"
-          // RadioGroup의 options는 mutable 배열을 요구해 도메인의 ReadonlyArray를 그대로 넘길
-          // 수 없다(components/ui는 design 레인 소관이라 시그니처를 바꾸지 않는다) — 얕은
-          // 복사로 새 mutable 배열을 만들어 넘긴다.
-          options={[...DECORATION_ITEM_TYPE_OPTIONS]}
-          defaultValue={item?.itemType ?? defaultItemType}
-        />
-      </Field>
-      <FieldError message={errors.itemType} />
+      <FoxTab
+        type="line"
+        size="lg"
+        label="아이템 유형"
+        value={itemType}
+        onValueChange={(value) => setItemType(value as DecorationItemType)}
+      >
+        {DECORATION_ITEM_TYPE_OPTIONS.map((option) => (
+          <FoxTabItem key={option.value} value={option.value}>
+            {option.label}
+          </FoxTabItem>
+        ))}
+      </FoxTab>
+      <input type="hidden" name="itemType" value={itemType} />
 
-      <Field label="아이템명 *">
-        <Input
-          type="text"
-          name="name"
-          defaultValue={item?.name ?? ''}
-          placeholder="아이템명을 입력하세요."
-        />
-      </Field>
-      <FieldError message={errors.name} />
+      <div className={styles.fields}>
+        <div className={styles.field}>{itemIdField}</div>
 
-      <Field label="카테고리 *">
-        <Select
-          name="categoryCode"
-          defaultValue={
-            item?.categoryCode ?? DEFAULT_DECORATION_ITEM_CATEGORY_CODE
-          }
-        >
-          {DECORATION_ITEM_CATEGORIES.map((category) => (
-            <option key={category.code} value={category.code}>
-              {category.label}
-            </option>
-          ))}
-        </Select>
-      </Field>
-      <FieldError message={errors.categoryCode} />
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="아이템명"
+            requirement="required"
+            defaultValue={item?.name ?? ''}
+            placeholder="아이템명을 입력하세요."
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+            maxLength={100}
+          />
+        </div>
 
-      <Field label="포인트(오픈 가능한 포인트) *">
-        <Input
-          type="number"
-          name="points"
-          min={0}
-          step={1}
-          defaultValue={item?.points}
-          placeholder="오픈 가능한 포인트"
-        />
-      </Field>
-      <FieldError message={errors.points} />
+        <div className={styles.field}>
+          <FoxSelect
+            size="md"
+            label="카테고리"
+            options={categories.map((category) => ({
+              value: category.code,
+              label: category.label,
+            }))}
+            placeholder="카테고리를 선택하세요."
+            value={categoryCode}
+            onValueChange={setCategoryCode}
+            error={Boolean(errors.categoryCode)}
+            hint={errors.categoryCode}
+          />
+          <input type="hidden" name="categoryCode" value={categoryCode} />
+        </div>
 
-      <Field label="썸네일 이미지 *">
-        <Input type="file" name="imageFile" accept="image/*" />
-      </Field>
-      {/* 수정 팝업에서 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는
-          빈 문자열이라 파일을 고르지 않으면 검증에서 걸린다. */}
-      <input
-        type="hidden"
-        name="imageFileId"
-        defaultValue={item?.imageFileId ?? ''}
-      />
-      {item?.imageFileId && (
-        <p className="text-body-sm text-foreground-muted">
-          현재 등록된 이미지가 있습니다. 새로 선택하면 교체됩니다.
-        </p>
-      )}
-      <FieldError message={errors.imageFileId} />
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            type="number"
+            name="points"
+            label="포인트(오픈 가능한 포인트)"
+            requirement="required"
+            min={0}
+            step={1}
+            defaultValue={item?.points}
+            placeholder="숫자를 입력하세요."
+            invalid={Boolean(errors.points)}
+            message={errors.points}
+          />
+        </div>
 
-      <Field label="설명">
-        <Input
-          type="text"
-          name="description"
-          defaultValue={item?.description ?? ''}
-          placeholder="설명을 입력하세요."
-        />
-      </Field>
-      <FieldError message={errors.description} />
+        <div className={styles.field}>
+          <FoxFileUpload
+            mode="image"
+            name="imageFile"
+            label="썸네일 이미지"
+            requirement="required"
+            accept="image/jpeg,image/png"
+            selectLabel="파일선택"
+            description={IMAGE_DESCRIPTION}
+            thumbnailWidth={120}
+            thumbnailHeight={120}
+            defaultFiles={
+              // 수정 팝업은 이미 등록된 이미지를 썸네일 자리에 먼저 보여준다.
+              item?.imageUrl
+                ? [
+                    {
+                      id: item.imageFileId ?? 'current',
+                      name: item.name,
+                      previewUrl: item.imageUrl,
+                    },
+                  ]
+                : undefined
+            }
+            onRemove={() => setRetainedImageFileId('')}
+          />
+          <input type="hidden" name="imageFileId" value={retainedImageFileId} />
+          {errors.imageFileId && (
+            <FoxHelperText type="danger" message={errors.imageFileId} />
+          )}
+        </div>
 
-      <Field label="사용여부">
-        <RadioGroup
-          name="isActive"
-          options={ACTIVE_STATUS_OPTIONS}
-          defaultValue={String(item?.isActive ?? true)}
-        />
-      </Field>
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="description"
+            label="설명"
+            defaultValue={item?.description ?? ''}
+            placeholder="설명을 입력하세요."
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+            maxLength={500}
+          />
+        </div>
 
-      <Field label={`정렬순서 (총 등록 ${typeTotalCount}개)`}>
-        <Input
-          type="number"
-          name="sortOrder"
-          min={1}
-          step={1}
-          defaultValue={item?.sortOrder ?? typeTotalCount + 1}
-        />
-      </Field>
-      <FieldError message={errors.sortOrder} />
+        <div className={styles.field}>
+          <FoxFormLabel as="span">사용여부</FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            name="isActive"
+            value="true"
+            label={formatDecorationItemActiveLabel(isActive)}
+            checked={isActive}
+            onChange={setIsActive}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            type="number"
+            name="sortOrder"
+            label="정렬순서"
+            min={1}
+            step={1}
+            defaultValue={item?.sortOrder ?? typeTotalCount + 1}
+            placeholder="0번"
+            suffix={`/ 총 ${typeTotalCount}개`}
+            invalid={Boolean(errors.sortOrder)}
+            message={errors.sortOrder}
+          />
+        </div>
+      </div>
     </>
   );
-}
-
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
-export function FieldError({ message }: { message?: string }) {
-  if (!message) {
-    return null;
-  }
-  return <p className="text-body-sm text-danger">{message}</p>;
 }
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
@@ -1,73 +0,0 @@
-'use client';
-
-import { useRouter } from 'next/navigation';
-import { useState, type ChangeEvent } from 'react';
-import { Button } from '@/components/ui/button';
-import { Select } from '@/components/ui/select';
-import {
-  DECORATION_ITEM_PAGE_SIZE_OPTIONS,
-  buildDecorationItemHref,
-  type DecorationItemPageSize,
-  type DecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-import { DecorationItemCreateModal } from './decoration-item-create-modal';
-
-interface DecorationItemListToolbarProps {
-  query: DecorationItemQuery;
-  /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록 팝업의 "(총 등록 N개)" 힌트에 쓴다. */
-  typeTotalCount: number;
-}
-
-/**
- * 목록 툴바(시안 ADM_ITM_101) — 페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를
- * 다시 조회하게 하고 1페이지로 되돌린다. 시안에 admins의 "정렬" select·엑셀다운로드에
- * 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다.
- *
- * [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이
- * 컴포넌트가 소유한다(관리자 회원 목록과 동일한 패턴).
- */
-export function DecorationItemListToolbar({
-  query,
-  typeTotalCount,
-}: DecorationItemListToolbarProps) {
-  const router = useRouter();
-  const [isCreateOpen, setIsCreateOpen] = useState(false);
-
-  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
-    const pageSize = Number(event.target.value) as DecorationItemPageSize;
-    router.replace(buildDecorationItemHref(query, { pageSize, page: 1 }));
-  }
-
-  return (
-    <div className="flex flex-wrap items-center justify-end gap-3">
-      <Button
-        type="button"
-        variant="primary"
-        onClick={() => setIsCreateOpen(true)}
-      >
-        + 신규 등록
-      </Button>
-
-      <Select
-        name="pageSize"
-        aria-label="페이지 크기"
-        defaultValue={query.pageSize}
-        onChange={handlePageSizeChange}
-      >
-        {DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => (
-          <option key={size} value={size}>
-            {size}개씩
-          </option>
-        ))}
-      </Select>
-
-      {isCreateOpen && (
-        <DecorationItemCreateModal
-          defaultItemType={query.itemType}
-          typeTotalCount={typeTotalCount}
-          onClose={() => setIsCreateOpen(false)}
-        />
-      )}
-    </div>
-  );
-}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-list.module.scss (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.module.scss
@@ -0,0 +1,13 @@
+// 목록 표의 썸네일 — 시안 ADM_ITM_101의 `img-thumb`(80×80 · 1px 테두리 · radius 4).
+// 그림은 잘라내지 않고 상자 안에 다 보이게 담는다(contain) — 아이템 생김새가 정보다.
+// @fox에 이미지 조각이 없어 이 화면 전용으로 둔다(값은 전부 @fox 토큰).
+
+@use "@fox/styles/abstracts" as fox;
+
+.thumbnail {
+  inline-size: 8rem;
+  block-size: 8rem;
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(2);
+  object-fit: contain;
+}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx (added)
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list.tsx
@@ -0,0 +1,243 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import { FoxTab } from '@fox/core/components/fox-tab';
+import { FoxTabItem } from '@fox/core/components/fox-tab-item';
+import { FoxPlusIcon } from '@fox/core/icons';
+import type { CommonCode } from '@/lib/domain/common-code';
+import {
+  DECORATION_ITEM_TYPE_OPTIONS,
+  EMPTY_FIELD_PLACEHOLDER,
+  formatDecorationItemActiveLabel,
+  formatDecorationItemCategory,
+  formatDecorationItemPoints,
+  formatDecorationItemUpdatedAt,
+  type DecorationItem,
+  type DecorationItemType,
+} from '@/lib/domain/decoration-item';
+import {
+  DECORATION_ITEM_PAGE_SIZE_OPTIONS,
+  DECORATION_ITEM_SEARCH_FIELD_OPTIONS,
+  buildDecorationItemHref,
+  type DecorationItemPageSize,
+  type DecorationItemQuery,
+  type DecorationItemSearchField,
+} from '@/lib/domain/decoration-item-query';
+import { DecorationItemCreateModal } from './decoration-item-create-modal';
+import { DecorationItemRowActions } from './decoration-item-row-actions';
+import styles from './decoration-item-list.module.scss';
+
+interface DecorationItemListProps {
+  items: DecorationItem[];
+  query: DecorationItemQuery;
+  /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+  /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록·수정 팝업의 정렬순서 힌트에 쓴다. */
+  typeTotalCount: number;
+  /** 공통코드 `ITEM_CATE_CD` — 등록·수정 팝업의 카테고리 선택지. */
+  categories: CommonCode[];
+}
+
+/**
+ * 꾸미기 아이템 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ITM_101 (5227:3549)
+ *
+ * 뼈대는 `FoxListContainer`가 그리고, 이 파일은 이 화면 고유의 것만 갖는다 — 유형 탭, 10개 열의
+ * 정의, 페이지 크기 셀렉트, 신규 등록 버튼.
+ *
+ * **유형 탭이 이 화면의 중심이다.** 셋트아이템은 별도 화면이 없어 탭이 목록의 조회 조건
+ * (`itemType`)만 바꾼다 — 그래서 검색어·페이지 크기와 같은 층위의 상태이고, 다른 조건과
+ * 마찬가지로 URL이 소유한다. 링크(`href`)가 아니라 `router.replace`를 쓰는 이유는 두 가지다.
+ * 같은 화면 안의 전환이라 `role="tablist"` 의미가 맞고(FoxTabItem은 `href`를 주면 탭 역할을
+ * 뗀다), 평범한 `<a>`는 App Router에서 문서 전체를 다시 받는다.
+ *
+ * 탭을 바꾸면 검색어까지 지운다 — 유형이 다르면 아이템 집합 자체가 달라 이전 검색어를 이어
+ * 붙이면 "결과 없음"만 보게 된다.
+ */
+export function DecorationItemList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+  typeTotalCount,
+  categories,
+}: DecorationItemListProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+
+  /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
+  function go(patch: Partial<DecorationItemQuery>) {
+    router.replace(buildDecorationItemHref(query, { ...patch, page: 1 }));
+  }
+
+  const columns: FoxListColumn<DecorationItem>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 저장된 값이 아니라 현재 페이지 기준 표시 순번이다. 목록이 정렬순서 오름차순이라
+      // "가장 작은 정렬순서가 1번"이 되도록 앞에서부터 센다.
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'itemSn', header: '아이템ID', width: 236 },
+    {
+      key: 'thumbnail',
+      header: '썸네일',
+      width: 160,
+      render: (row) =>
+        row.imageUrl ? (
+          // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`를 쓰려면 백엔드
+          // 호스트를 next.config에 등록해야 해 여기서는 <img>로 둔다.
+          // eslint-disable-next-line @next/next/no-img-element
+          <img
+            className={styles.thumbnail}
+            src={row.imageUrl}
+            alt={`${row.name} 썸네일`}
+          />
+        ) : (
+          EMPTY_FIELD_PLACEHOLDER
+        ),
+    },
+    { key: 'name', header: '아이템명', width: 236 },
+    {
+      key: 'category',
+      header: '카테고리',
+      width: 160,
+      render: formatDecorationItemCategory,
+    },
+    {
+      key: 'points',
+      header: '포인트',
+      width: 160,
+      render: (row) => formatDecorationItemPoints(row.points),
+    },
+    {
+      key: 'isActive',
+      header: '사용여부',
+      width: 120,
+      render: (row) => (
+        <FoxStatusIndicator
+          type={row.isActive ? 'success' : 'danger'}
+          label={formatDecorationItemActiveLabel(row.isActive)}
+        />
+      ),
+    },
+    { key: 'sortOrder', header: '정렬순서', width: 80 },
+    {
+      key: 'updatedAt',
+      header: '수정일시',
+      width: 160,
+      render: (row) => formatDecorationItemUpdatedAt(row.updatedAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 160,
+      render: (row) => (
+        <DecorationItemRowActions
+          item={row}
+          typeTotalCount={typeTotalCount}
+          categories={categories}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<DecorationItem>
+        title="꾸미기 아이템 목록"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '꾸미기아이템관리' },
+          { label: '아이템 관리' },
+        ]}
+        caption="꾸미기 아이템 목록"
+        tabs={
+          <FoxTab
+            type="line"
+            size="lg"
+            label="아이템 유형"
+            value={query.itemType}
+            onValueChange={(value) =>
+              go({ itemType: value as DecorationItemType, keyword: '' })
+            }
+          >
+            {DECORATION_ITEM_TYPE_OPTIONS.map((option) => (
+              <FoxTabItem key={option.value} value={option.value}>
+                {option.label}
+              </FoxTabItem>
+            ))}
+          </FoxTab>
+        }
+        columns={columns}
+        rows={items}
+        rowKey={(row) => String(row.itemSn)}
+        totalCount={totalCount}
+        emptyMessage="등록된 아이템이 없습니다. 다른 검색어로 다시 시도해 주세요."
+        sorts={
+          <FoxSelectText
+            size="sm"
+            ariaLabel="페이지 크기"
+            options={DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => ({
+              value: String(size),
+              label: `${size}개씩 보기`,
+            }))}
+            value={String(query.pageSize)}
+            onValueChange={(value) =>
+              go({ pageSize: Number(value) as DecorationItemPageSize })
+            }
+          />
+        }
+        searchFields={DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        searchField={query.searchField}
+        keyword={query.keyword}
+        toolbarActions={
+          <FoxButton
+            type="primary"
+            size="md"
+            leadingIcon={<FoxPlusIcon />}
+            label="신규 등록"
+            onAction={() => setIsCreateOpen(true)}
+          />
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildDecorationItemHref(query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined || patch.searchField !== undefined) {
+            go({
+              keyword: String(patch.keyword ?? ''),
+              searchField: (patch.searchField ??
+                query.searchField) as DecorationItemSearchField,
+            });
+          }
+        }}
+      />
+
+      {isCreateOpen && (
+        <DecorationItemCreateModal
+          defaultItemType={query.itemType}
+          typeTotalCount={typeTotalCount}
+          categories={categories}
+          onClose={() => setIsCreateOpen(false)}
+        />
+      )}
+    </>
+  );
+}
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,35 +1,34 @@
 'use client';
 
 import { useState, useTransition } from 'react';
-import { Button } from '@/components/ui/button';
+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 { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { DecorationItem } from '@/lib/domain/decoration-item';
 import { deleteDecorationItemAction } from '../_actions';
 import { DecorationItemEditModal } from './decoration-item-edit-modal';
 
 interface DecorationItemRowActionsProps {
   item: DecorationItem;
-  /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
+  /** 수정 팝업의 "/ 총 N개" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
   typeTotalCount: number;
+  categories: CommonCode[];
 }
 
 /**
- * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용
- * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다, admins와 동일 패턴).
+ * 목록 행의 "관리" 셀(시안 ADM_ITM_101) — 시안의 btn-ico 두 개(28×28 · 테두리 있는 default
+ * 계열)를 4px 간격으로 세운다.
  *
- * **삭제는 시안대로 확인 얼럿을 거친 뒤 실행한다** — 전역 얼럿(FeedbackProvider)의 actions
- * 슬롯에 직접 [취소]/[삭제] 버튼을 넣어 구성한다. 삭제는 되돌릴 수 없는 동작이라 얼럿 문구에
- * 명시한다.
- *
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
+ * **삭제는 확인 얼럿을 거친다** — 전역 얼럿(FeedbackProvider)의 actions 슬롯에 [취소]/[삭제]를
+ * 넣어 구성한다. 되돌릴 수 없는 동작이라 얼럿 문구에 명시한다.
  */
 export function DecorationItemRowActions({
   item,
   typeTotalCount,
+  categories,
 }: DecorationItemRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isEditOpen, setIsEditOpen] = useState(false);
@@ -57,42 +56,41 @@
       message: `${item.name}(아이템ID ${item.itemSn})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
       actions: (
         <>
-          <Button type="button" variant="ghost" onClick={hideAlert}>
-            취소
-          </Button>
-          <Button type="button" variant="primary" onClick={runDelete}>
-            삭제
-          </Button>
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
         </>
       ),
     });
   }
 
   return (
-    <div className="flex items-center gap-1">
-      <FoxIconButton
-        type="ghost"
-        size="sm"
-        icon={<FoxPencilSimpleIcon />}
-        label="수정"
-        onAction={() => setIsEditOpen(true)}
-      />
-      <FoxIconButton
-        type="ghost"
-        size="sm"
-        icon={<FoxTrashIcon />}
-        label={isDeleting ? '삭제 중' : '삭제'}
-        disabled={isDeleting}
-        onAction={confirmDelete}
-      />
+    <>
+      <FoxButtonGroup size="sm">
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxPencilSimpleIcon />}
+          label="수정"
+          onAction={() => setIsEditOpen(true)}
+        />
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxTrashIcon />}
+          label={isDeleting ? '삭제 중' : '삭제'}
+          disabled={isDeleting}
+          onAction={confirmDelete}
+        />
+      </FoxButtonGroup>
 
       {isEditOpen && (
         <DecorationItemEditModal
           item={item}
           typeTotalCount={typeTotalCount}
+          categories={categories}
           onClose={() => setIsEditOpen(false)}
         />
       )}
-    </div>
+    </>
   );
 }
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
@@ -1,74 +0,0 @@
-import Link from 'next/link';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
-import {
-  DECORATION_ITEMS_PATH,
-  DECORATION_ITEM_SEARCH_FIELD_OPTIONS,
-  DEFAULT_DECORATION_ITEM_SEARCH_FIELD,
-  buildDecorationItemHref,
-  type DecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-
-interface DecorationItemSearchBarProps {
-  query: DecorationItemQuery;
-}
-
-/**
- * 검색 영역(시안 ADM_ITM_101) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다
- * (자바스크립트 없이도 동작). 현재 `itemType`(유형 탭)·`pageSize`는 hidden input으로 함께
- * 제출해 검색 후에도 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를
- * 두지 않아 `parseDecorationItemQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼
- * 제출이 아니라 기본값(검색대상·검색어)으로 되돌린 링크라 현재 입력값과 무관하게 동작한다
- * (유형 탭은 초기화 대상이 아니다 — 지금 보고 있는 목록은 유지). 상호작용이 전부 네이티브 폼
- * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다
- * (관리자 회원 목록 이전 검색바와 동일한 설계).
- */
-export function DecorationItemSearchBar({ query }: DecorationItemSearchBarProps) {
-  const resetHref = buildDecorationItemHref(query, {
-    searchField: DEFAULT_DECORATION_ITEM_SEARCH_FIELD,
-    keyword: '',
-    page: 1,
-  });
-
-  return (
-    <form
-      method="get"
-      action={DECORATION_ITEMS_PATH}
-      className="flex flex-wrap items-end gap-3"
-    >
-      <input type="hidden" name="itemType" value={query.itemType} />
-      <input type="hidden" name="pageSize" value={query.pageSize} />
-
-      <Field label="검색 대상">
-        <Select name="searchField" defaultValue={query.searchField}>
-          {DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => (
-            <option key={option.value} value={option.value}>
-              {option.label}
-            </option>
-          ))}
-        </Select>
-      </Field>
-
-      <Field label="검색어">
-        <Input
-          type="text"
-          name="keyword"
-          defaultValue={query.keyword}
-          placeholder="검색어를 입력하세요."
-        />
-      </Field>
-
-      <Button type="submit" variant="secondary">
-        검색
-      </Button>
-      <Link
-        href={resetHref}
-        className="text-body-md text-foreground-muted underline-offset-2 hover:underline"
-      >
-        초기화
-      </Link>
-    </form>
-  );
-}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-table.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-table.tsx
@@ -1,109 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  EMPTY_FIELD_PLACEHOLDER,
-  formatDecorationItemActiveLabel,
-  formatDecorationItemCategory,
-  formatDecorationItemPoints,
-  formatDecorationItemUpdatedAt,
-  type DecorationItem,
-} from '@/lib/domain/decoration-item';
-import { DecorationItemRowActions } from './decoration-item-row-actions';
-
-interface DecorationItemTableProps {
-  items: DecorationItem[];
-  /** 1-based 현재 페이지. "번호" 열의 표시 순번 계산에 쓴다. */
-  page: number;
-  pageSize: number;
-  /** 수정 팝업의 "(총 등록 N개)" 힌트로 그대로 흘려보낸다(현재 유형 전체 등록 건수). */
-  typeTotalCount: number;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '아이템ID',
-  '썸네일',
-  '아이템명',
-  '카테고리',
-  '포인트',
-  '사용여부',
-  '정렬순서',
-  '수정일시',
-  '관리',
-];
-
-/**
- * 꾸미기 아이템 목록 테이블(시안 ADM_ITM_101, 10컬럼).
- *
- * **"번호"는 admins와 달리 오름차순 표시 순번이다**(`offset + index + 1`) — 이 화면의 기본
- * 정렬이 정렬순서 오름차순이라(Repository가 보장) "가장 작은 정렬순서가 1번"이 직관적이다.
- * admins가 전체 건수에서 거꾸로 세는 이유(생성일 최신순 정렬)가 여기에는 없다.
- *
- * **썸네일은 백엔드 이미지 URL을 그대로 건다** — 등록한 파일 ID(`atchFileId`)로 만든
- * `GET /api/v1/common/file/image` 주소이며, 이 GET은 인증이 필요 없어 브라우저가 백엔드를 직접
- * 호출한다(사용자 확정 사항). URL 문자열은 서버에서 만들어 내려주므로 백엔드 주소 환경변수는
- * 여전히 서버 전용이다. `next/image`가 아니라 `<img>`를 쓰는 이유는 외부 호스트를
- * `next.config`에 등록해야 하는 설정 변경을 이번 범위(로직)에서 하지 않기 때문이다.
- *
- * "관리" 열은 행별 수정/삭제 트리거(`DecorationItemRowActions`)에 위임한다 — 상호작용이
- * 필요한 것은 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트
- * 경계로 뗀다.
- */
-export function DecorationItemTable({
-  items,
-  page,
-  pageSize,
-  typeTotalCount,
-}: DecorationItemTableProps) {
-  const offset = (page - 1) * pageSize;
-
-  return (
-    <Table>
-      <TableHead>
-        <TableRow>
-          {COLUMN_HEADERS.map((header) => (
-            <TableHeaderCell key={header}>{header}</TableHeaderCell>
-          ))}
-        </TableRow>
-      </TableHead>
-      <TableBody>
-        {items.map((item, index) => (
-          <TableRow key={item.itemSn}>
-            <TableCell>{offset + index + 1}</TableCell>
-            <TableCell>{item.itemSn}</TableCell>
-            <TableCell>
-              {item.imageUrl ? (
-                // eslint-disable-next-line @next/next/no-img-element -- 위 주석 참조(외부 호스트 설정 회피)
-                <img
-                  src={item.imageUrl}
-                  alt={`${item.name} 썸네일`}
-                  className="size-9 rounded-md object-cover"
-                />
-              ) : (
-                EMPTY_FIELD_PLACEHOLDER
-              )}
-            </TableCell>
-            <TableCell>{item.name}</TableCell>
-            <TableCell>{formatDecorationItemCategory(item)}</TableCell>
-            <TableCell>{formatDecorationItemPoints(item.points)}</TableCell>
-            <TableCell>{formatDecorationItemActiveLabel(item.isActive)}</TableCell>
-            <TableCell>{item.sortOrder}</TableCell>
-            <TableCell>{formatDecorationItemUpdatedAt(item.updatedAt)}</TableCell>
-            <TableCell>
-              <DecorationItemRowActions
-                item={item}
-                typeTotalCount={typeTotalCount}
-              />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
 
app/(protected)/(basic)/decoration-items/_components/decoration-item-type-tabs.tsx (deleted)
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-type-tabs.tsx
@@ -1,59 +0,0 @@
-import Link from 'next/link';
-import { DECORATION_ITEM_TYPE_OPTIONS } from '@/lib/domain/decoration-item';
-import {
-  buildDecorationItemHref,
-  type DecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-
-interface DecorationItemTypeTabsProps {
-  query: DecorationItemQuery;
-}
-
-const TAB_BASE_CLASSES =
-  'inline-flex h-10 items-center justify-center rounded-md px-4 text-body-md transition-colors duration-200';
-const ACTIVE_TAB_CLASSES = 'bg-primary text-on-primary';
-const INACTIVE_TAB_CLASSES =
-  'border border-border bg-surface text-foreground-muted hover:bg-surface-muted hover:text-foreground';
-
-/**
- * 유형 탭(시안 ADM_ITM_101 — 개별아이템/셋트아이템 전환). **셋트아이템은 별도 화면이 없고 이
- * 탭이 같은 목록·같은 폼의 유형만 바꾼다**는 것이 시안 두 곳에 명시된 사양이다
- * (`decoration-item-query.ts` 상단 주석 참조).
- *
- * 값이 URL(`itemType` 쿼리 파라미터)에 실리므로 순수 `next/link` 내비게이션으로 충분해
- * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다.
- *
- * 탭 전환 시 검색어/검색대상/페이지크기는 유지하고 페이지만 1로 되돌린다(admin-member의
- * 정렬/페이지크기 전환과 같은 규칙) — 다른 유형으로 넘어가면 전체 건수 자체가 달라지기
- * 때문이다. 전용 탭 컴포넌트가 없어(§10.3, 신설은 design 레인 소관) 버튼 형태 Link 2개로
- * 구성했다.
- */
-export function DecorationItemTypeTabs({ query }: DecorationItemTypeTabsProps) {
-  return (
-    <div
-      role="tablist"
-      aria-label="아이템 유형"
-      className="flex items-center gap-2"
-    >
-      {DECORATION_ITEM_TYPE_OPTIONS.map((option) => {
-        const isActive = option.value === query.itemType;
-        return (
-          <Link
-            key={option.value}
-            href={buildDecorationItemHref(query, {
-              itemType: option.value,
-              page: 1,
-            })}
-            role="tab"
-            aria-selected={isActive}
-            className={`${TAB_BASE_CLASSES} ${
-              isActive ? ACTIVE_TAB_CLASSES : INACTIVE_TAB_CLASSES
-            }`}
-          >
-            {option.label}
-          </Link>
-        );
-      })}
-    </div>
-  );
-}
app/(protected)/(basic)/decoration-items/page.tsx
--- app/(protected)/(basic)/decoration-items/page.tsx
+++ app/(protected)/(basic)/decoration-items/page.tsx
@@ -1,99 +1,60 @@
 import type { Metadata } from 'next';
-import Link from 'next/link';
-import { Alert } from '@/components/ui/alert';
-import { Pagination } from '@/components/ui/pagination';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchDecorationItems } from '@/lib/data/repositories/decoration-item-repository';
-import {
-  buildDecorationItemHref,
-  parseDecorationItemQuery,
-} from '@/lib/domain/decoration-item-query';
-import { DecorationItemListToolbar } from './_components/decoration-item-list-toolbar';
-import { DecorationItemSearchBar } from './_components/decoration-item-search-bar';
-import { DecorationItemTable } from './_components/decoration-item-table';
-import { DecorationItemTypeTabs } from './_components/decoration-item-type-tabs';
+import { CODE_GROUP } from '@/lib/domain/common-code';
+import { parseDecorationItemQuery } from '@/lib/domain/decoration-item-query';
+import { DecorationItemList } from './_components/decoration-item-list';
 
 export const metadata: Metadata = {
   title: '꾸미기 아이템',
 };
+
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를
+// 원천 차단하기 위해 명시적으로 강제한다(학생 회원 목록과 동일).
+export const dynamic = 'force-dynamic';
 
 interface PageProps {
   searchParams: Promise<Record<string, string | string[] | undefined>>;
 }
 
 /**
- * 꾸미기 아이템 목록(시안 ADM_ITM_101).
+ * 꾸미기 아이템 목록(시안 ADM_ITM_101 — 5227:3549).
  *
  * **셋트아이템은 별도 화면이 없다** — 유형 탭(개별/셋트)만 바꿔 같은 목록·같은 폼을 재사용
- * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 다른 검색·페이지
- * 상태와 마찬가지로 URL(searchParams)이 단일 진실원천이다(`useState`로 들지 않는다).
- *
- * `verifySession()`을 호출만 하고 반환값을 쓰지 않는 이유는 인증 확인 자체가 목적이기
- * 때문이다 — admins의 "본인 계정 삭제 방지" 같은 사용자 조건부 로직이 이 화면에는 없다.
+ * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 검색·페이지와 마찬가지로
+ * URL(searchParams)이 단일 진실원천이다.
  */
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
 
   const query = parseDecorationItemQuery(await searchParams);
-  const { items, totalCount, isTotalCountExact, typeTotalCount } =
-    await fetchDecorationItems(query);
+  // 목록과 카테고리는 서로를 기다릴 이유가 없어 함께 띄운다.
+  const [{ items, totalCount, isTotalCountExact, typeTotalCount }, categories] =
+    await Promise.all([
+      fetchDecorationItems(query),
+      fetchCommonCodes(CODE_GROUP.decorationItemCategory),
+    ]);
 
   // 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를
   // 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다
-  // (학생 목록과 동일한 보정. Repository 주석 참조).
+  // (Repository 주석 참조).
   const totalPages = isTotalCountExact
     ? Math.max(1, Math.ceil(totalCount / query.pageSize))
     : query.page + 1;
-  // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐, 또는 유형 탭 전환으로
-  // 전체 건수가 줄어듦) 마지막 페이지로 맞춘다 — 표의 순번 계산도 이 값을 기준으로 해야 헤더의
-  // "현재페이지"와 어긋나지 않는다.
+  // 요청 페이지가 범위를 벗어나면(삭제로 마지막 페이지가 사라짐, 유형 탭 전환으로 건수가 줄어듦)
+  // 마지막 페이지로 맞춘다 — 표의 순번도 이 값을 기준으로 세야 페이지 표시와 어긋나지 않는다.
   const currentPage = Math.min(query.page, totalPages);
 
   return (
-    <section className="flex flex-1 flex-col gap-6 p-8">
-      <h1 className="text-headline-sm text-foreground">꾸미기 아이템 목록</h1>
-
-      <DecorationItemTypeTabs query={query} />
-
-      <DecorationItemSearchBar query={query} />
-
-      <DecorationItemListToolbar query={query} typeTotalCount={typeTotalCount} />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}개{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
-        {currentPage}
-        {isTotalCountExact ? `/${totalPages}` : ''}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="등록된 아이템이 없습니다."
-          actions={
-            <Link
-              href={buildDecorationItemHref(query, { keyword: '', page: 1 })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          <DecorationItemTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-            typeTotalCount={typeTotalCount}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) => buildDecorationItemHref(query, { page })}
-          />
-        </>
-      )}
-    </section>
+    <DecorationItemList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+      typeTotalCount={typeTotalCount}
+      categories={categories}
+    />
   );
 }
app/(protected)/(basic)/students/_actions.ts
--- app/(protected)/(basic)/students/_actions.ts
+++ app/(protected)/(basic)/students/_actions.ts
@@ -1,34 +1,49 @@
 'use server';
 
+import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
+import { updateStudentMemberUseYn } from '@/lib/data/repositories/student-member-repository';
+import { STUDENT_MEMBERS_PATH } from '@/lib/domain/student-member-query';
 
 export type UpdateStudentActiveStatusState =
   | { status: 'idle' }
   | { status: 'error'; error: string }
   | { status: 'success' };
 
-const UNSUPPORTED_ERROR =
-  '사용여부 변경은 아직 제공되지 않습니다. (백엔드 API 준비 중)';
+const INVALID_INPUT_ERROR = '요청 값이 올바르지 않습니다.';
 
 /**
- * 학생 회원 사용여부 변경 Server Action — **현재는 미지원 상태의 이식 지점**이다.
+ * 학생 회원 사용여부 변경 Server Action.
  *
- * 목록이 백엔드 API(`GET /api/v1/mngr/user/pagination`)로 전환되면서 이 화면의 데이터 원천은
- * 백엔드가 됐지만, 사용여부는 **조회 응답에 값도 없고 변경 API도 없다.** 이전의 mock 쓰기를
- * 그대로 두면 mock 배열에만 존재하는 id를 찾다가 "존재하지 않는 학생 회원" 오류가 나므로
- * (목록의 id는 이제 백엔드 `userId`다) 쓰기 경로를 명시적으로 막았다. 팝업의 사용여부 라디오와
- * 저장 버튼도 같은 이유로 비활성이다.
+ *   PUT /api/v1/mngr/user/{userId}/{useYn}
  *
- * 변경 API가 생기면 `formData: FormData` 인자를 되살리고 본문을 "입력 검증 → Repository 호출
- * → `revalidatePath`"로 되돌리면 된다(지금은 읽을 입력이 없어 인자를 받지 않는다 — 인자를
- * 줄여도 `useActionState`의 호출 규약에는 어긋나지 않는다). 인증 확인을 본문 맨 앞에 남겨 둔
- * 것도 그 형태를 유지하기 위함이다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어
- * 이 확인이 유일한 최종 방어선이다.
+ * 백엔드에 값도 변경 API도 없어 한동안 막아 두었던 경로다. edupay-backend develop 4d98756에서
+ * 목록 응답에 `useYn`이 실리고 변경 API가 생기면서 되살렸다.
+ *
+ * 인증 확인을 본문 맨 앞에 둔다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 이
+ * 확인이 유일한 최종 방어선이다. 실패 사유는 일반화된 문구만 화면으로 보낸다(백엔드 message
+ * 원문에는 내부 정보가 실릴 수 있다).
  */
 export async function updateStudentActiveStatus(
-  _prevState: UpdateStudentActiveStatusState
+  _prevState: UpdateStudentActiveStatusState,
+  formData: FormData
 ): Promise<UpdateStudentActiveStatusState> {
   await verifySession();
 
-  return { status: 'error', error: UNSUPPORTED_ERROR };
+  const id = formData.get('id');
+  if (typeof id !== 'string' || id.trim() === '') {
+    return { status: 'error', error: INVALID_INPUT_ERROR };
+  }
+
+  // 체크박스는 켜졌을 때만 값이 실린다 — 없으면 꺼진 것이다.
+  const isActive = formData.get('isActive') !== null;
+
+  const result = await updateStudentMemberUseYn(id, isActive);
+  if (!result.ok) {
+    return { status: 'error', error: result.message };
+  }
+
+  // 목록의 사용여부 열이 방금 바꾼 값을 반영해야 한다.
+  revalidatePath(STUDENT_MEMBERS_PATH);
+  return { status: 'success' };
 }
app/(protected)/(basic)/students/_components/student-detail-modal.tsx
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
@@ -1,6 +1,6 @@
 'use client';
 
-import { useActionState, useRef, useState } from 'react';
+import { useActionState, useEffect, useRef, useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import {
   FoxDescriptionList,
@@ -8,7 +8,9 @@
 } from '@fox/core/components/fox-description-list';
 import { FoxModal } from '@fox/core/components/fox-modal';
 import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
 import {
+  EMPTY_FIELD_PLACEHOLDER,
   formatGradeClassNumber,
   formatOptionalValue,
   type StudentMember,
@@ -44,9 +46,8 @@
  * 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼
  * 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다.
  *
- * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이지만, 백엔드 목록 응답에 값이 없고 변경
- * API도 없어 토글·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). 폼·Server
- * Action 배선은 남겨 두었으므로 API가 생기면 `IS_ACTIVE_STATUS_EDITABLE`만 되돌리면 된다.
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이다. 토글을 넘기면 화면이 바로 반응하고,
+ * 저장을 누르면 `PUT /api/v1/mngr/user/{userId}/{useYn}`로 나간다.
  *
  * 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
  * 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그
@@ -59,8 +60,20 @@
   );
   const formRef = useRef<HTMLFormElement>(null);
   // 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면
-  // 스위치를 넘겨도 라벨이 그대로다. 저장이 되지 않으므로 이 값은 화면에만 머문다.
+  // 스위치를 넘겨도 라벨이 그대로다. 저장하면 이 값이 폼에 실려 나간다.
   const [isActive, setIsActive] = useState(member.isActive ?? false);
+
+  // 학년·반이 하나도 없으면 `-`가 오는데, 그때는 "학교 / -"가 아니라 학교만 보여야 한다.
+  const formattedGradeClass = formatGradeClassNumber(member);
+  const gradeClassNumber =
+    formattedGradeClass === EMPTY_FIELD_PLACEHOLDER ? null : formattedGradeClass;
+
+  // 저장에 성공하면 닫는다. 목록은 Server Action의 revalidatePath가 다시 그린다.
+  useEffect(() => {
+    if (state.status === 'success') {
+      onClose();
+    }
+  }, [state, onClose]);
 
   const items: FoxDescriptionItem[] = [
     { key: 'name', term: '이름', description: member.name },
@@ -68,7 +81,7 @@
     {
       key: 'phoneNumber',
       term: '휴대전화 번호',
-      description: formatOptionalValue(member.phoneNumber),
+      description: formatOptionalValue(formatPhoneNumber(member.phoneNumber)),
     },
     {
       key: 'email',
@@ -83,17 +96,15 @@
     {
       key: 'guardian',
       term: '보호자 이름 / 연락처',
-      description: joinPair(member.guardianName, member.guardianPhoneNumber),
+      description: joinPair(
+        member.guardianName,
+        formatPhoneNumber(member.guardianPhoneNumber)
+      ),
     },
     {
       key: 'school',
       term: '학교 / 학년·반·번호',
-      description: joinPair(
-        member.schoolName,
-        formatGradeClassNumber(member) === '-'
-          ? null
-          : formatGradeClassNumber(member)
-      ),
+      description: joinPair(member.schoolName, gradeClassNumber),
     },
     {
       key: 'isActive',
app/(protected)/(basic)/students/_components/student-list.tsx
--- app/(protected)/(basic)/students/_components/student-list.tsx
+++ app/(protected)/(basic)/students/_components/student-list.tsx
@@ -13,7 +13,11 @@
   FoxArrowsDownUpIcon,
   FoxDownloadSimpleIcon,
 } from '@fox/core/icons';
-import type { StudentMember } from '@/lib/domain/student-member';
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
+import {
+  formatGradeClassNumber,
+  type StudentMember,
+} from '@/lib/domain/student-member';
 import {
   STUDENT_MEMBERS_EXCEL_PATH,
   STUDENT_MEMBER_PAGE_SIZE_OPTIONS,
@@ -39,16 +43,6 @@
 /** 값이 없으면 시안처럼 자리를 비우지 않고 `-`로 채운다. */
 function text(value: string | number | null | undefined) {
   return value === null || value === undefined || value === '' ? '-' : String(value);
-}
-
-/** 학년·반·번호는 시안이 "2학년 5반 7번" 한 칸으로 묶는다. */
-function formatClass(member: StudentMember) {
-  const parts = [
-    member.grade === null ? null : `${member.grade}학년`,
-    member.classNumber === null ? null : `${member.classNumber}반`,
-    member.studentNumber === null ? null : `${member.studentNumber}번`,
-  ].filter(Boolean);
-  return parts.length > 0 ? parts.join(' ') : '-';
 }
 
 /**
@@ -94,7 +88,7 @@
       key: 'phoneNumber',
       header: '휴대전화번호',
       width: 160,
-      render: (row) => text(row.phoneNumber),
+      render: (row) => text(formatPhoneNumber(row.phoneNumber)),
     },
     { key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) },
     {
@@ -113,7 +107,7 @@
       width: 160,
       render: (row) => text(row.schoolName),
     },
-    { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatClass },
+    { key: 'classInfo', header: '학년/반/번호', width: 160, render: formatGradeClassNumber },
     {
       key: 'guardianName',
       header: '보호자이름',
@@ -124,7 +118,7 @@
       key: 'guardianPhoneNumber',
       header: '보호자연락처',
       width: 160,
-      render: (row) => text(row.guardianPhoneNumber),
+      render: (row) => text(formatPhoneNumber(row.guardianPhoneNumber)),
     },
     {
       key: 'joinedAt',
 
app/(protected)/(basic)/system/codes/_actions.ts (added)
+++ app/(protected)/(basic)/system/codes/_actions.ts
@@ -0,0 +1,217 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createCodeDetail,
+  createCodeGroup,
+  deleteCodeDetail,
+  deleteCodeGroup,
+  updateCodeDetail,
+  updateCodeGroup,
+} from '@/lib/data/repositories/common-code-repository';
+import {
+  validateCommonCodeDetail,
+  validateCommonCodeGroup,
+  type CommonCodeFormState,
+} from '@/lib/domain/common-code-form';
+import { COMMON_CODES_PATH } from '@/lib/domain/common-code-query';
+
+/**
+ * 코드관리 등록/수정/삭제 Server Action(기획 SYS_COD_001).
+ *
+ * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
+ * POST될 수 있어 이 확인이 유일한 최종 방어선이다.
+ *
+ * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/common-code-form.ts`의 규칙을 호출) —
+ * 화면의 required 속성과 읽기 전용 표시는 편의일 뿐 신뢰 경계가 아니다.
+ *
+ * **수정 대상(코드ID)은 읽기 전용 입력이 아니라 hidden 필드에서 읽는다** — 읽기 전용 칸은
+ * 위조될 수 있고, 위조되더라도 대상이 바뀌면 안 되기 때문이다.
+ */
+
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+/**
+ * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 함정을 피하려고 빈 문자열은
+ * 명시적으로 `NaN`으로 둔다 — 그래야 검증이 "값이 비었음"을 잡아낸다.
+ */
+function parseFormNumber(formData: FormData, key: string): number {
+  const trimmed = readString(formData, key).trim();
+  return trimmed === '' ? NaN : Number(trimmed);
+}
+
+/* ── 공통코드 ─────────────────────────────────────────────────────────────── */
+
+/** 기획 [공통코드 등록] 팝업. */
+export async function createCodeGroupAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const validation = validateCommonCodeGroup({
+    comCd: readString(formData, 'comCd'),
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await createCodeGroup(validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/** 기획 [공통코드 수정] 팝업 — 코드ID는 대상을 가리키는 값이라 바뀌지 않는다. */
+export async function updateCodeGroupAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const targetComCd = readString(formData, 'targetComCd').trim();
+  if (!targetComCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateCommonCodeGroup({
+    comCd: targetComCd,
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await updateCodeGroup(targetComCd, validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/**
+ * 기획 ④ 삭제 — 확인 얼럿은 화면이 띄우고 여기서는 인증과 입력만 확인한다.
+ *
+ * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
+ * (prevState, formData) 규약 대신 식별자를 직접 받는다.
+ */
+export async function deleteCodeGroupAction(
+  comCd: string
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  if (!comCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await deleteCodeGroup(comCd);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/* ── 상세코드 ─────────────────────────────────────────────────────────────── */
+
+/** 기획 [상세코드 등록] 팝업 — 코드ID는 좌측에서 고른 공통코드다. */
+export async function createCodeDetailAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const validation = validateCommonCodeDetail({
+    comCd: readString(formData, 'comCd'),
+    comDtlCd: readString(formData, 'comDtlCd'),
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+    sortSeq: parseFormNumber(formData, 'sortSeq'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await createCodeDetail(validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/** 기획 [상세코드 수정] 팝업 — 코드ID·코드는 대상을 가리키는 값이라 바뀌지 않는다. */
+export async function updateCodeDetailAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const targetComCd = readString(formData, 'targetComCd').trim();
+  const targetComDtlCd = readString(formData, 'targetComDtlCd').trim();
+  if (!targetComCd || !targetComDtlCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateCommonCodeDetail({
+    comCd: targetComCd,
+    comDtlCd: readString(formData, 'comDtlCd'),
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+    sortSeq: parseFormNumber(formData, 'sortSeq'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await updateCodeDetail(targetComCd, targetComDtlCd, validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+export async function deleteCodeDetailAction(
+  comCd: string,
+  comDtlCd: string
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  if (!comCd || !comDtlCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await deleteCodeDetail(comCd, comDtlCd);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/system/codes/_components/code-detail-modal.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-detail-modal.tsx
@@ -0,0 +1,163 @@
+'use client';
+
+import { useActionState, useEffect, useRef } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCodeDetail } from '@/lib/domain/common-code';
+import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form';
+import { createCodeDetailAction, updateCodeDetailAction } from '../_actions';
+import styles from './code-form.module.scss';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+
+interface CodeDetailModalProps {
+  /** 소속 공통코드 — 좌측에서 고른 값이라 팝업에서는 바꿀 수 없다. */
+  comCd: string;
+  /** 수정 대상. 없으면 등록 팝업이다. */
+  detail?: CommonCodeDetail;
+  /** 등록 시 채워 둘 정렬번호(기존 개수 + 1). */
+  nextSortSeq: number;
+  onClose: () => void;
+}
+
+/**
+ * 상세코드 등록/수정 팝업 — 기획 1842:16179 / 1842:16262.
+ *
+ * 코드ID는 좌측에서 고른 공통코드라 **항상 읽기 전용**이다(기획 ①: "상위코드 고정된 경우 비활성
+ * 처리"). 수정에서는 코드(`comDtlCd`)도 대상을 가리키는 값이라 바꿀 수 없다 — 백엔드가 두 코드로
+ * 행을 찾기 때문에 바꾸면 다른 행을 수정하게 된다.
+ *
+ * ⚠️ **등록 시 코드설명은 저장되지 않는다** — 백엔드 INSERT에 `DTL_CD_EXPLN`이 빠져 있다.
+ * 시안대로 칸은 두고 백엔드에 수정을 요청했다(Repository 주석 참조). 수정에서는 정상 저장된다.
+ */
+export function CodeDetailModal({
+  comCd,
+  detail,
+  nextSortSeq,
+  onClose,
+}: CodeDetailModalProps) {
+  const isEdit = detail !== undefined;
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateCodeDetailAction : createCodeDetailAction,
+    INITIAL_COMMON_CODE_FORM_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isEdit ? '상세코드를 수정했습니다.' : '상세코드를 등록했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isEdit]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <FoxModal
+      open
+      size="sm"
+      title={isEdit ? '상세코드 수정' : '상세코드 등록'}
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : '저장'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
+      }
+    >
+      <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}>
+        <input type="hidden" name={isEdit ? 'targetComCd' : 'comCd'} value={comCd} />
+        {isEdit && (
+          <input
+            type="hidden"
+            name="targetComDtlCd"
+            value={detail.comDtlCd}
+          />
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            label="코드ID"
+            value={comCd}
+            readOnly
+            message="상위코드가 고정된 경우 비활성 처리됩니다."
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="comDtlCd"
+            label="코드"
+            requirement="required"
+            defaultValue={detail?.comDtlCd ?? ''}
+            readOnly={isEdit}
+            placeholder="코드를 입력하세요."
+            invalid={Boolean(errors.comDtlCd)}
+            message={errors.comDtlCd}
+            maxLength={50}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="코드값의미"
+            requirement="required"
+            defaultValue={detail?.name ?? ''}
+            placeholder="코드값의미를 입력하세요."
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+            maxLength={100}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            type="number"
+            name="sortSeq"
+            label="정렬번호"
+            requirement="required"
+            min={1}
+            step={1}
+            defaultValue={detail?.sortSeq ?? nextSortSeq}
+            invalid={Boolean(errors.sortSeq)}
+            message={errors.sortSeq}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="description"
+            label="코드설명"
+            defaultValue={detail?.description ?? ''}
+            placeholder="코드설명을 입력하세요."
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+            maxLength={500}
+          />
+        </div>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/system/codes/_components/code-form.module.scss (added)
+++ app/(protected)/(basic)/system/codes/_components/code-form.module.scss
@@ -0,0 +1,20 @@
+// 등록·수정 팝업의 입력 줄 — 꾸미기 아이템 팝업(ADM_ITM_102_p)과 같은 배치를 쓴다.
+// 기획 와이어프레임에는 구분선이 없지만 그건 시각 시안이 아니라서, 이 프로젝트에서 확정된
+// 팝업 폼 모양을 따른다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+.field {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block: fox.padding(6);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
 
app/(protected)/(basic)/system/codes/_components/code-group-modal.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-group-modal.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  CODE_ID_PREFIXES,
+  DEFAULT_CODE_ID_PREFIX,
+  applyCodeIdPrefix,
+  readCodeIdPrefix,
+  type CommonCodeGroup,
+} from '@/lib/domain/common-code';
+import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form';
+import { createCodeGroupAction, updateCodeGroupAction } from '../_actions';
+import styles from './code-form.module.scss';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+
+interface CodeGroupModalProps {
+  /** 수정 대상. 없으면 등록 팝업이다. */
+  group?: CommonCodeGroup;
+  onClose: () => void;
+}
+
+/**
+ * 공통코드 등록/수정 팝업 — 기획 1842:16056 / 1842:16122.
+ *
+ * 두 팝업은 제목·버튼 글자와 **코드ID의 읽기 전용 여부**만 다르고 입력 항목이 같아 한 조각으로 둔다.
+ *
+ * **분류코드는 저장되지 않는다.** `TB_SYS_COM_CD`에 해당 컬럼이 없어(확인함) 이 셀렉트는 코드ID
+ * 접두사를 채워 주는 입력 보조로만 동작한다 — 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라
+ * 그 작명 규칙을 화면이 거드는 것이다(사용자 확정 사항). 저장되는 값은 코드ID 하나다.
+ *
+ * 저장 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — ref로 직접 제출한다(다른 팝업과 같은 방식).
+ */
+export function CodeGroupModal({ group, onClose }: CodeGroupModalProps) {
+  const isEdit = group !== undefined;
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateCodeGroupAction : createCodeGroupAction,
+    INITIAL_COMMON_CODE_FORM_STATE
+  );
+
+  const [comCd, setComCd] = useState(group?.comCd ?? '');
+  const [prefix, setPrefix] = useState(
+    group ? readCodeIdPrefix(group.comCd) : DEFAULT_CODE_ID_PREFIX
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isEdit ? '공통코드를 수정했습니다.' : '공통코드를 등록했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isEdit]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <FoxModal
+      open
+      size="sm"
+      title={isEdit ? '공통코드 수정' : '공통코드 등록'}
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : '저장'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
+      }
+    >
+      <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}>
+        {/* 수정 대상은 읽기 전용 칸이 아니라 이 값으로 정해진다 — 칸이 위조돼도 대상은 안 바뀐다. */}
+        {isEdit && (
+          <input type="hidden" name="targetComCd" value={group.comCd} />
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name={isEdit ? undefined : 'comCd'}
+            label="코드ID"
+            requirement="required"
+            value={comCd}
+            onChange={setComCd}
+            readOnly={isEdit}
+            placeholder="코드ID를 입력하세요."
+            invalid={Boolean(errors.comCd)}
+            message={errors.comCd}
+            maxLength={50}
+          />
+        </div>
+
+        {!isEdit && (
+          <div className={styles.field}>
+            <FoxSelect
+              size="md"
+              label="분류코드"
+              options={CODE_ID_PREFIXES.map((value) => ({
+                value,
+                label: value,
+              }))}
+              value={prefix}
+              onValueChange={(next) => {
+                setPrefix(next);
+                setComCd((current) => applyCodeIdPrefix(current, next));
+              }}
+              hint="코드ID 앞에 붙는 분류입니다. 별도로 저장되지 않습니다."
+            />
+          </div>
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="코드ID명"
+            requirement="required"
+            defaultValue={group?.name ?? ''}
+            placeholder="코드ID명을 입력하세요."
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+            maxLength={100}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="description"
+            label="코드ID설명"
+            defaultValue={group?.description ?? ''}
+            placeholder="코드ID설명을 입력하세요."
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+            maxLength={500}
+          />
+        </div>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/system/codes/_components/code-management.module.scss (added)
+++ app/(protected)/(basic)/system/codes/_components/code-management.module.scss
@@ -0,0 +1,45 @@
+// 코드관리 화면 배치 — 시안 5402:13399의 `row`(공통코드 756 + 40 + 상세코드 756 = 1552).
+// 두 목록이 나란히 서는 것은 이 화면만의 배치라 @fox에 넣지 않고 여기 둔다(값은 @fox 토큰).
+
+@use "@fox/styles/abstracts" as fox;
+
+.page {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+// 좁은 화면에서는 두 목록이 위아래로 선다 — 나란히 두면 표가 눌려 읽을 수 없다.
+.columns {
+  display: grid;
+  inline-size: 100%;
+  gap: fox.gap(9);
+  grid-template-columns: 1fr;
+}
+
+@include fox.pc {
+  .columns {
+    grid-template-columns: 1fr 1fr;
+  }
+}
+
+.panel {
+  display: flex;
+  // 표가 넓어도 칸이 늘어나지 않게 한다 — 늘어나면 옆 칸을 밀어 두 목록의 폭이 어긋난다.
+  min-inline-size: 0;
+  flex-direction: column;
+}
+
+// 설명 열은 한 줄로 줄이고 넘치면 말줄임한다.
+//
+// 시안의 셀 컴포넌트에는 말줄임 스타일이 없지만(전문이 들어 있고 word-break만 걸려 있다),
+// 프레임 높이가 44로 고정이라 Figma가 넘치는 글자를 잘라 보여준다. CSS는 대신 감싸므로 그대로
+// 두면 행마다 높이가 달라진다 — 설명은 자유 입력이라 길이를 예측할 수 없다.
+// 잘린 글자는 title로 남겨 마우스를 올리면 전문이 보인다.
+.truncate {
+  display: block;
+  inline-size: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
 
app/(protected)/(basic)/system/codes/_components/code-management.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
@@ -0,0 +1,343 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHeadingGroup } from '@fox/core/components/fox-heading-group';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxPlusIcon } from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  formatOptionalText,
+  type CommonCodeDetail,
+  type CommonCodeGroup,
+} from '@/lib/domain/common-code';
+import {
+  COMMON_CODE_PAGE_SIZE,
+  COMMON_CODE_SEARCH_FIELD_OPTIONS,
+  buildCommonCodeHref,
+  type CommonCodeQuery,
+  type CommonCodeSearchField,
+} from '@/lib/domain/common-code-query';
+import { deleteCodeDetailAction, deleteCodeGroupAction } from '../_actions';
+import { CodeDetailModal } from './code-detail-modal';
+import { CodeGroupModal } from './code-group-modal';
+import { CodeRowActions } from './code-row-actions';
+import styles from './code-management.module.scss';
+
+interface CodeManagementProps {
+  query: CommonCodeQuery;
+  groups: CommonCodeGroup[];
+  groupTotalCount: number;
+  groupPage: number;
+  groupTotalPages: number;
+  /** 화면이 실제로 보여주는 선택 — URL 값이 목록에 없으면 서버가 첫 행으로 바꿔 넘긴다. */
+  selectedComCd: string | null;
+  details: CommonCodeDetail[];
+  detailTotalCount: number;
+  detailPage: number;
+  detailTotalPages: number;
+  nextSortSeq: number;
+}
+
+/**
+ * 설명 칸 — 한 줄로 줄이고 넘치면 말줄임한다(스타일 주석 참조). 잘린 글자를 읽을 방법이
+ * 있어야 하므로 전문을 `title`로 남긴다.
+ */
+function Truncated({ text }: { text: string | null }) {
+  const value = formatOptionalText(text);
+  return (
+    <span className={styles.truncate} title={text ?? undefined}>
+      {value}
+    </span>
+  );
+}
+
+/** 어느 팝업이 열려 있는지. 한 번에 하나만 열린다. */
+type OpenModal =
+  | { kind: 'group-create' }
+  | { kind: 'group-edit'; group: CommonCodeGroup }
+  | { kind: 'detail-create' }
+  | { kind: 'detail-edit'; detail: CommonCodeDetail }
+  | null;
+
+/**
+ * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399
+ *
+ * 목록이 둘이지만 화면은 하나다. 좌측 공통코드를 **행 전체로 눌러** 고르면 우측 상세코드가 그
+ * 그룹으로 갱신된다(기획 ④) — 선택은 URL에 실리므로 갱신은 서버가 다시 그려 준다.
+ *
+ * 두 목록 모두 `FoxListContainer`를 쓰되 화면 제목은 위에서 한 번만 그린다(`FoxPageHeader`).
+ * 패널 이름은 `FoxHeadingGroup`이다 — 화면에 여럿 설 수 있는 구역 제목이라 `<h2>`로 나간다.
+ *
+ * 우측에는 검색이 없다(시안) — 상세코드는 선택된 그룹에 속한 것이 전부라 걸러 낼 대상이 아니다.
+ */
+export function CodeManagement({
+  query,
+  groups,
+  groupTotalCount,
+  groupPage,
+  groupTotalPages,
+  selectedComCd,
+  details,
+  detailTotalCount,
+  detailPage,
+  detailTotalPages,
+  nextSortSeq,
+}: CodeManagementProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [modal, setModal] = useState<OpenModal>(null);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function go(patch: Partial<CommonCodeQuery>) {
+    router.replace(buildCommonCodeHref(query, patch));
+  }
+
+  /**
+   * 삭제는 되돌릴 수 없어 확인 얼럿을 거친다(기획 ④). 성공 뒤 화면 갱신은 Server Action의
+   * `revalidatePath`가 맡으므로 여기서는 알림만 띄운다.
+   */
+  function confirmDelete(
+    title: string,
+    message: string,
+    run: () => Promise<{ status: string; message?: string }>
+  ) {
+    showAlert({
+      variant: 'danger',
+      title,
+      message,
+      actions: (
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton
+            type="error"
+            size="md"
+            label="삭제"
+            onAction={() => {
+              hideAlert();
+              startDeleting(async () => {
+                const result = await run();
+                if (result.status === 'error') {
+                  showToast({
+                    variant: 'danger',
+                    message: result.message ?? '삭제하지 못했습니다.',
+                  });
+                  return;
+                }
+                showToast({ variant: 'success', message: '삭제했습니다.' });
+              });
+            }}
+          />
+        </>
+      ),
+    });
+  }
+
+  const groupColumns: FoxListColumn<CommonCodeGroup>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (groupPage - 1) * COMMON_CODE_PAGE_SIZE + index + 1,
+    },
+    { key: 'comCd', header: '코드ID', width: 120 },
+    { key: 'name', header: '코드명', width: 160, emphasis: true },
+    {
+      key: 'description',
+      header: '코드설명',
+      width: 156,
+      render: (row) => <Truncated text={row.description} />,
+    },
+    {
+      key: 'createdAt',
+      header: '생성일',
+      width: 120,
+      render: (row) => formatOptionalText(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => (
+        <CodeRowActions
+          target={row.comCd}
+          deleting={isDeleting}
+          onEdit={() => setModal({ kind: 'group-edit', group: row })}
+          onDelete={() =>
+            confirmDelete(
+              '공통코드를 삭제하시겠습니까?',
+              `${row.name}(${row.comCd})을(를) 삭제합니다. 속한 상세코드도 함께 쓸 수 없게 됩니다.`,
+              () => deleteCodeGroupAction(row.comCd)
+            )
+          }
+        />
+      ),
+    },
+  ];
+
+  const detailColumns: FoxListColumn<CommonCodeDetail>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 기획의 상세코드 목록은 이 자리에 정렬번호를 보여준다(FSC01→1, FSC10→10).
+      render: (row) => row.sortSeq,
+    },
+    { key: 'comDtlCd', header: '상세코드ID', width: 120 },
+    { key: 'name', header: '상세코드명', width: 120, emphasis: true },
+    {
+      key: 'description',
+      header: '상세코드설명',
+      width: 196,
+      render: (row) => <Truncated text={row.description} />,
+    },
+    {
+      key: 'createdAt',
+      header: '생성일',
+      width: 120,
+      render: (row) => formatOptionalText(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => (
+        <CodeRowActions
+          target={row.comDtlCd}
+          deleting={isDeleting}
+          onEdit={() => setModal({ kind: 'detail-edit', detail: row })}
+          onDelete={() =>
+            confirmDelete(
+              '상세코드를 삭제하시겠습니까?',
+              `${row.name}(${row.comDtlCd})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+              () => deleteCodeDetailAction(row.comCd, row.comDtlCd)
+            )
+          }
+        />
+      ),
+    },
+  ];
+
+  return (
+    <div className={styles.page}>
+      <FoxPageHeader
+        title="코드관리"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '코드관리' },
+        ]}
+      />
+
+      <div className={styles.columns}>
+        <section className={styles.panel}>
+          <FoxHeadingGroup title="공통코드 목록" />
+          <FoxListContainer<CommonCodeGroup>
+            caption="공통코드 목록"
+            columns={groupColumns}
+            rows={groups}
+            rowKey={(row) => row.comCd}
+            totalCount={groupTotalCount}
+            emptyMessage="조회된 코드가 없습니다. 다른 검색어로 다시 시도해 주세요."
+            searchFields={COMMON_CODE_SEARCH_FIELD_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            searchField={query.searchField}
+            keyword={query.keyword}
+            toolbarActions={
+              <FoxButton
+                type="primary"
+                size="md"
+                leadingIcon={<FoxPlusIcon />}
+                label="신규 등록"
+                onAction={() => setModal({ kind: 'group-create' })}
+              />
+            }
+            onRowSelect={(row) => go({ comCd: row.comCd })}
+            page={groupPage}
+            totalPages={groupTotalPages}
+            buildHref={(patch) =>
+              buildCommonCodeHref(query, { page: Number(patch.page ?? 1) })
+            }
+            onQueryChange={(patch) => {
+              if (patch.keyword !== undefined || patch.searchField !== undefined) {
+                go({
+                  keyword: String(patch.keyword ?? ''),
+                  searchField: (patch.searchField ??
+                    query.searchField) as CommonCodeSearchField,
+                  // 검색하면 목록 자체가 달라지므로 선택도 첫 행으로 다시 잡게 비운다.
+                  comCd: null,
+                  page: 1,
+                });
+              }
+            }}
+          />
+        </section>
+
+        <section className={styles.panel}>
+          <FoxHeadingGroup title="상세코드 목록" />
+          <FoxListContainer<CommonCodeDetail>
+            caption="상세코드 목록"
+            columns={detailColumns}
+            rows={details}
+            rowKey={(row) => `${row.comCd}:${row.comDtlCd}`}
+            totalCount={detailTotalCount}
+            emptyMessage={
+              selectedComCd
+                ? '등록된 상세코드가 없습니다.'
+                : '왼쪽에서 공통코드를 선택해 주세요.'
+            }
+            searchHidden
+            toolbarActions={
+              <FoxButton
+                type="primary"
+                size="md"
+                leadingIcon={<FoxPlusIcon />}
+                label="상세코드 등록"
+                // 소속될 공통코드가 없으면 등록할 수 없다.
+                disabled={!selectedComCd}
+                onAction={() => setModal({ kind: 'detail-create' })}
+              />
+            }
+            page={detailPage}
+            totalPages={detailTotalPages}
+            buildHref={(patch) =>
+              buildCommonCodeHref(query, {
+                comCd: selectedComCd,
+                detailPage: Number(patch.page ?? 1),
+              })
+            }
+          />
+        </section>
+      </div>
+
+      {modal?.kind === 'group-create' && (
+        <CodeGroupModal onClose={() => setModal(null)} />
+      )}
+      {modal?.kind === 'group-edit' && (
+        <CodeGroupModal group={modal.group} onClose={() => setModal(null)} />
+      )}
+      {modal?.kind === 'detail-create' && selectedComCd && (
+        <CodeDetailModal
+          comCd={selectedComCd}
+          nextSortSeq={nextSortSeq}
+          onClose={() => setModal(null)}
+        />
+      )}
+      {modal?.kind === 'detail-edit' && (
+        <CodeDetailModal
+          comCd={modal.detail.comCd}
+          detail={modal.detail}
+          nextSortSeq={nextSortSeq}
+          onClose={() => setModal(null)}
+        />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/system/codes/_components/code-row-actions.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-row-actions.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
+
+interface CodeRowActionsProps {
+  /** 읽어 줄 이름에 붙는 대상 — "CMS004 수정"처럼 읽힌다. */
+  target: string;
+  onEdit: () => void;
+  onDelete: () => void;
+  deleting?: boolean;
+}
+
+/**
+ * 두 목록이 함께 쓰는 "관리" 셀(시안 5402:13455) — 28×28 테두리 버튼 두 개.
+ *
+ * **누름이 위로 새지 않게 막는다** — 공통코드 행은 눌러서 고르는 행이라, 막지 않으면 수정·삭제를
+ * 누를 때 선택까지 함께 바뀐다. 상세코드 행은 고를 수 없지만 같은 조각을 쓰므로 규칙을 여기 둔다.
+ */
+export function CodeRowActions({
+  target,
+  onEdit,
+  onDelete,
+  deleting = false,
+}: CodeRowActionsProps) {
+  return (
+    <span
+      onClick={(event) => event.stopPropagation()}
+      onKeyDown={(event) => event.stopPropagation()}
+    >
+      <FoxButtonGroup size="sm">
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxPencilSimpleIcon />}
+          label={`${target} 수정`}
+          onAction={onEdit}
+        />
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxTrashIcon />}
+          label={deleting ? `${target} 삭제 중` : `${target} 삭제`}
+          disabled={deleting}
+          onAction={onDelete}
+        />
+      </FoxButtonGroup>
+    </span>
+  );
+}
 
app/(protected)/(basic)/system/codes/page.tsx (added)
+++ app/(protected)/(basic)/system/codes/page.tsx
@@ -0,0 +1,107 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  fetchCodeDetails,
+  fetchCodeGroups,
+} from '@/lib/data/repositories/common-code-repository';
+import type { CommonCodeGroup } from '@/lib/domain/common-code';
+import {
+  COMMON_CODE_DETAIL_PAGE_SIZE,
+  COMMON_CODE_PAGE_SIZE,
+  parseCommonCodeQuery,
+  type CommonCodeQuery,
+} from '@/lib/domain/common-code-query';
+import { CodeManagement } from './_components/code-management';
+
+export const metadata: Metadata = {
+  title: '코드관리',
+};
+
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를
+// 원천 차단하기 위해 명시적으로 강제한다.
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+/** 부분일치 검색 — 백엔드가 완전일치만 지원해 여기서 거른다(Repository 주석 참조). */
+function filterGroups(
+  groups: CommonCodeGroup[],
+  query: CommonCodeQuery
+): CommonCodeGroup[] {
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return groups;
+  }
+
+  return groups.filter((group) => {
+    const target = query.searchField === 'comCd' ? group.comCd : group.name;
+    return target.toLowerCase().includes(keyword);
+  });
+}
+
+/** 1-based 페이지를 잘라낸다. 범위를 벗어난 페이지는 마지막 페이지로 맞춘다. */
+function paginate<T>(rows: T[], page: number, pageSize: number) {
+  const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
+  const currentPage = Math.min(Math.max(page, 1), totalPages);
+  const offset = (currentPage - 1) * pageSize;
+
+  return {
+    rows: rows.slice(offset, offset + pageSize),
+    currentPage,
+    totalPages,
+    totalCount: rows.length,
+  };
+}
+
+/**
+ * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399
+ *
+ * 좌측 공통코드를 고르면 우측 상세코드 목록이 그 그룹으로 갱신된다. 선택·검색·두 목록의 페이지가
+ * 모두 URL에 실려, 이 서버 컴포넌트가 매 요청 그 조건대로 데이터를 만들어 넘긴다.
+ *
+ * **검색·페이징을 서버에서 처리한다** — 백엔드가 두 목록 모두 전체를 반환하고 검색도 완전일치라
+ * (Repository 주석 참조), 부분일치 필터와 페이지 자르기를 여기서 한다. 전체를 받으므로 총건수는
+ * 정확하다(다른 목록 화면의 하한값 보정이 여기에는 필요 없다).
+ *
+ * **선택된 공통코드가 목록에 없으면 첫 행으로 대체한다** — 검색으로 걸러졌거나 삭제된 코드가
+ * URL에 남아 있으면 우측이 영영 비어 보이기 때문이다.
+ */
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseCommonCodeQuery(await searchParams);
+
+  const groups = filterGroups(await fetchCodeGroups(), query);
+  const groupPage = paginate(groups, query.page, COMMON_CODE_PAGE_SIZE);
+
+  const selectedComCd =
+    groups.find((group) => group.comCd === query.comCd)?.comCd ??
+    groupPage.rows[0]?.comCd ??
+    null;
+
+  const details = selectedComCd ? await fetchCodeDetails(selectedComCd) : [];
+  const detailPage = paginate(
+    details,
+    query.detailPage,
+    COMMON_CODE_DETAIL_PAGE_SIZE
+  );
+
+  return (
+    <CodeManagement
+      query={query}
+      groups={groupPage.rows}
+      groupTotalCount={groupPage.totalCount}
+      groupPage={groupPage.currentPage}
+      groupTotalPages={groupPage.totalPages}
+      selectedComCd={selectedComCd}
+      details={detailPage.rows}
+      detailTotalCount={detailPage.totalCount}
+      detailPage={detailPage.currentPage}
+      detailTotalPages={detailPage.totalPages}
+      // 새 상세코드의 기본 정렬번호 — 시안 등록 팝업이 기존 개수 다음 번호를 채워 둔다.
+      nextSortSeq={details.length + 1}
+    />
+  );
+}
 
lib/data/repositories/common-code-repository.ts (added)
+++ lib/data/repositories/common-code-repository.ts
@@ -0,0 +1,247 @@
+import 'server-only';
+import { cache } from 'react';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import type {
+  CommonCode,
+  CommonCodeDetail,
+  CommonCodeGroup,
+} from '@/lib/domain/common-code';
+import type {
+  CommonCodeDetailValues,
+  CommonCodeGroupValues,
+} from '@/lib/domain/common-code-form';
+
+/**
+ * 공통코드 Repository — 그룹(`TB_SYS_COM_CD`)과 상세(`TB_SYS_COM_CD_DTL`) 두 층을 모두 맡는다.
+ *
+ * ```
+ * GET    /api/v1/mngr/code/list                  그룹 목록 (ROLE_ADMIN)
+ * GET    /api/v1/mngr/code/list/{comCd}          그룹의 상세코드 목록
+ * POST   /api/v1/mngr/code                       그룹 등록 — @RequestBody(JSON)
+ * PUT    /api/v1/mngr/code/{comCd}               그룹 수정 — @RequestBody(JSON)
+ * DELETE /api/v1/mngr/code/{comCd}               그룹 삭제 — soft delete(DEL_YN)
+ * POST   /api/v1/mngr/code/detail                상세 등록 — 어노테이션 없음 → form
+ * PUT    /api/v1/mngr/code/{comCd}/{comDtlCd}    상세 수정 — @RequestBody(JSON)
+ * DELETE /api/v1/mngr/code/{comCd}/{comDtlCd}    상세 삭제 — soft delete
+ * ```
+ *
+ * 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것:
+ *
+ * - **본문 형식이 엔드포인트마다 다르다.** 상세 등록만 `@ParameterObject`(form)이고 나머지 쓰기는
+ *   전부 `@RequestBody`(JSON)다 — 한쪽으로 통일해 보내면 반대쪽이 조용히 깨진다.
+ * - **두 목록 모두 페이징이 없다**(전체 반환). 그래서 총건수는 정확하고, 자르는 일은 호출부가 한다.
+ * - **그룹 검색은 완전일치다**(`com_cd = #{}` / `cd_nm = #{}`). 시안은 부분검색이라 검색 파라미터를
+ *   쓰지 않고 전체를 받아 호출부가 거른다(사용자 확정 사항).
+ * - **정렬은 고정이다** — 두 목록 다 `ROW_NUMBER() OVER (...)`를 `ORDER BY RNUM DESC`로 뒤집는
+ *   이중 역순이라 결과는 등록일 오름차순이다. 화면에서 다시 정렬하지 않는다.
+ * - **삭제는 soft delete**이고 조회가 `DEL_YN != 'Y'`로 거른다.
+ *
+ * ⚠️ **백엔드 결함(보고함, 미수정)**
+ * 1. 상세 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지 않는다 → 상세코드설명이 늘 비어 온다.
+ * 2. 상세 등록 INSERT에도 `DTL_CD_EXPLN`이 없다 → 등록 시 입력한 설명이 저장되지 않는다.
+ *    (수정 UPDATE에는 있어 수정으로는 저장된다 — 다만 위 1 때문에 목록에서는 여전히 안 보인다.)
+ * 사용자 지시로 시안대로 화면을 두고 백엔드에 수정을 요청한다 — 고쳐지면 프론트 수정 없이 동작한다.
+ *
+ * 캐시: 조회는 `no-store` — 관리 화면이라 신선도가 우선이다. `cache()`는 한 요청 안의 중복 호출만
+ * 막는다(화면이 그리기용으로, Server Action이 검증용으로 같은 목록을 부른다).
+ */
+
+const CODE_PATH = '/api/v1/mngr/code';
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+async function requestList(path: string): Promise<unknown[]> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(path, {
+    method: 'GET',
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  if (!Array.isArray(result.data)) {
+    throw new Error('공통코드 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return result.data;
+}
+
+/**
+ * 응답 1건 → 도메인 타입. 코드값이 없는 행은 **예외 대신 건너뛴다** — 코드 한 줄이 깨졌다고
+ * 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 판단이다).
+ */
+function toGroup(raw: unknown): CommonCodeGroup[] {
+  if (!isRecord(raw)) {
+    return [];
+  }
+
+  const comCd = readString(raw, 'comCd');
+  if (!comCd) {
+    return [];
+  }
+
+  return [
+    {
+      comCd,
+      name: readString(raw, 'cdNm') ?? '',
+      description: readString(raw, 'cdExpln'),
+      createdAt: readString(raw, 'frstRegDtStr'),
+    },
+  ];
+}
+
+function toDetail(raw: unknown): CommonCodeDetail[] {
+  if (!isRecord(raw)) {
+    return [];
+  }
+
+  const comDtlCd = readString(raw, 'comDtlCd');
+  if (!comDtlCd) {
+    return [];
+  }
+
+  const sortSeq = raw.sortSeq;
+
+  return [
+    {
+      comCd: readString(raw, 'comCd') ?? '',
+      comDtlCd,
+      name: readString(raw, 'cdNm') ?? '',
+      // 지금은 백엔드가 내려 주지 않아 늘 null이다(파일 상단 결함 1).
+      description: readString(raw, 'dtlCdExpln'),
+      sortSeq: typeof sortSeq === 'number' ? sortSeq : 0,
+      createdAt: readString(raw, 'frstRegDtStr'),
+    },
+  ];
+}
+
+/** 공통코드(그룹) 전체. 검색·페이징은 호출부가 한다(파일 상단 주석 참조). */
+export const fetchCodeGroups = cache(async function fetchCodeGroups(): Promise<
+  CommonCodeGroup[]
+> {
+  const rows = await requestList(`${CODE_PATH}/list`);
+  return rows.flatMap(toGroup);
+});
+
+/** 한 그룹의 상세코드 전체. */
+export const fetchCodeDetails = cache(async function fetchCodeDetails(
+  comCd: string
+): Promise<CommonCodeDetail[]> {
+  const rows = await requestList(
+    `${CODE_PATH}/list/${encodeURIComponent(comCd)}`
+  );
+  return rows.flatMap(toDetail);
+});
+
+/**
+ * 다른 화면이 선택지로 쓰는 최소 표현. 상세코드 조회를 그대로 쓰되 화면이 알 필요 없는 것을
+ * 덜어 낸다 — 같은 `cache()`를 타므로 한 요청 안에서 왕복이 늘지 않는다.
+ */
+export async function fetchCommonCodes(groupCode: string): Promise<CommonCode[]> {
+  const details = await fetchCodeDetails(groupCode);
+  return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name }));
+}
+
+/*
+ * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
+ * 상세 등록만 form이고 나머지는 JSON이다(파일 상단 주석 참조).
+ * 성공 응답은 모두 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다.
+ */
+
+async function sendWrite(
+  path: string,
+  method: 'POST' | 'PUT' | 'DELETE',
+  payload?: { form: Record<string, string | number | undefined> } | { body: unknown }
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<null>(path, {
+    method,
+    ...payload,
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function createCodeGroup(
+  values: CommonCodeGroupValues
+): Promise<void> {
+  await sendWrite(CODE_PATH, 'POST', {
+    body: { comCd: values.comCd, cdNm: values.name, cdExpln: values.description },
+  });
+}
+
+/** 코드ID는 경로로만 간다 — 수정 대상이 아니라 대상을 가리키는 값이다. */
+export async function updateCodeGroup(
+  comCd: string,
+  values: CommonCodeGroupValues
+): Promise<void> {
+  await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'PUT', {
+    body: { cdNm: values.name, cdExpln: values.description },
+  });
+}
+
+export async function deleteCodeGroup(comCd: string): Promise<void> {
+  await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'DELETE');
+}
+
+/** 상세 등록만 form이다 — 컨트롤러가 `@RequestBody` 없이 받는다. */
+export async function createCodeDetail(
+  values: CommonCodeDetailValues
+): Promise<void> {
+  await sendWrite(`${CODE_PATH}/detail`, 'POST', {
+    form: {
+      comCd: values.comCd,
+      comDtlCd: values.comDtlCd,
+      cdNm: values.name,
+      // 백엔드 INSERT가 이 값을 쓰지 않는다(파일 상단 결함 2). 고쳐지면 그대로 저장된다.
+      dtlCdExpln: values.description,
+      sortSeq: values.sortSeq,
+    },
+  });
+}
+
+export async function updateCodeDetail(
+  comCd: string,
+  comDtlCd: string,
+  values: CommonCodeDetailValues
+): Promise<void> {
+  await sendWrite(
+    `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`,
+    'PUT',
+    {
+      body: {
+        cdNm: values.name,
+        dtlCdExpln: values.description,
+        sortSeq: values.sortSeq,
+      },
+    }
+  );
+}
+
+export async function deleteCodeDetail(
+  comCd: string,
+  comDtlCd: string
+): Promise<void> {
+  await sendWrite(
+    `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`,
+    'DELETE'
+  );
+}
lib/data/repositories/decoration-item-repository.ts
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
@@ -26,16 +26,20 @@
  * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 —
  * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController.
  *
- * - **등록과 수정의 본문 형식이 서로 다르다.** 등록(POST)은 `@ParameterObject`라 form이고,
- *   수정(PUT)은 `@RequestBody`라 JSON이다(2026-08-18 백엔드 `4d98756`에서 수정만 바뀌었다).
- *   한쪽 방식으로 통일해 보내면 조용히 깨진다 — form을 JSON 핸들러에 보내면 415이고, 반대는
- *   전 필드가 null인 채 저장된다(오류도 나지 않는다).
+ * - **등록과 수정의 본문 형식이 다르다.** 등록은 `@ParameterObject`(form 인코딩), 수정은
+ *   `@RequestBody`(JSON)로 받는다 — 한쪽 형식으로 통일해 보내면 반대쪽이 전 필드 null이 되거나
+ *   415로 떨어진다. 백엔드 커밋 "FIX API 수정"에서 수정만 JSON으로 바뀌었다.
  * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
  *   무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다.
  * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그
  *   값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청).
  * - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해
  *   결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다.
+ * - **수정일시는 `lastMdfcnDt`가 아니라 `lastMdfcnDtStr`로 온다.** VO의 `lastMdfcnDt`는
+ *   `@JsonIgnore`라 응답에 실리지 않고, SQL이 `DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로
+ *   내려 준다(`mapUnderscoreToCamelCase`).
+ * - **수정으로는 설명을 비울 수 없다** — UPDATE의 `<if test="itemExplan != ''">` 가드가 빈 문자열을
+ *   건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다.
  * - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한
  *   `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어
  *   하한값으로 보정한다.
@@ -139,8 +143,6 @@
     sortOrder: readNumber(raw, 'sortOrdr') ?? 0,
     imageFileId,
     imageUrl: buildImageUrl(imageFileId),
-    // 백엔드 목록 SQL의 별칭이 `last_mdfcn_dt_str`이다(2026-08-18 `4d98756`에서 개명).
-    // 지금은 컨트롤러가 수정일시를 채우지 않아 늘 비어 있지만, 채우기 시작하면 그대로 흐른다.
     updatedAt: readString(raw, 'lastMdfcnDtStr'),
   };
 }
@@ -251,11 +253,12 @@
 
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 보내는 값은 같고 **형식만 갈린다** — 등록은 form, 수정은 JSON(파일 상단 주석 참조).
+ * 보내는 값은 등록·수정이 같고 **실어 보내는 형식만 다르다** — 등록은 form, 수정은 JSON
+ * (파일 상단 주석 참조).
  */
 
 /** 등록·수정이 공유하는 전송 필드. */
-function buildWritePayload(
+function buildWriteForm(
   values: DecorationItemEditableValues
 ): Record<string, string | number | undefined> {
   return {
@@ -271,21 +274,23 @@
   };
 }
 
+/**
+ * 등록·수정 공통 호출. `payload`가 본문 형식을 정한다 — 이 한 곳에서만 갈린다.
+ *
+ * 등록·수정 성공 응답은 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다.
+ */
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT',
-  values: DecorationItemEditableValues
+  payload: { form: Record<string, string | number | undefined> } | { body: unknown }
 ): Promise<void> {
   const accessToken = await getSessionAccessToken();
-  const payload = buildWritePayload(values);
 
   const result = await backendFetch<null>(path, {
     method,
-    // 등록은 `@ParameterObject`(form), 수정은 `@RequestBody`(JSON) — 컨트롤러가 그렇게 갈린다.
-    ...(method === 'POST' ? { form: payload } : { body: payload }),
+    ...payload,
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
-    // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다.
     canHaveNullData: true,
   });
 
@@ -294,27 +299,27 @@
   }
 }
 
+/** 등록 — 컨트롤러가 `@ParameterObject`라 form 인코딩이다. */
 export async function createDecorationItem(
   values: DecorationItemEditableValues
 ): Promise<void> {
-  await sendWrite(DECORATION_ITEM_PATH, 'POST', values);
+  await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) });
 }
 
 /**
- * 수정. 종전에 "수정하면 아이템이 목록에서 사라지던" 백엔드 결함은 2026-08-18(`4d98756`)에
- * 해소됐다 — 컨트롤러가 `itemType`을 빌더에 담게 됐고 UPDATE 문도 `<if>`로 감싸졌다.
+ * 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를
+ * 보내지만 형식이 다르다.
  *
- * ⚠️ 남은 백엔드 결함 둘:
- * - **설명을 비워도 지워지지 않는다** — UPDATE의 `ITEM_EXPLAN`이 `<if test="… != ''">` 안에
- *   있어 빈 문자열은 SET 절에서 빠진다.
- * - **수정일시가 채워지지 않는다** — 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 UPDATE가
- *   `LAST_MDFCN_DT = null`을 쓴다. 그래서 응답의 `lastMdfcnDtStr`도 계속 비어 있다.
+ * UPDATE 문이 필드마다 `<if>`로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일
+ * 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다.
  */
 export async function updateDecorationItem(
   itemSn: number,
   values: DecorationItemEditableValues
 ): Promise<void> {
-  await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', values);
+  await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', {
+    body: buildWriteForm(values),
+  });
 }
 
 export async function deleteDecorationItem(itemSn: number): Promise<void> {
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -70,6 +70,12 @@
             { id: 'faqs', label: 'FAQ', href: '/boards/faqs' },
           ],
         },
+        {
+          id: 'system',
+          label: '시스템관리',
+          icon: 'wrench',
+          children: [{ id: 'codes', label: '코드관리', href: '/system/codes' }],
+        },
       ],
     },
   ],
lib/data/repositories/student-member-repository.ts
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
@@ -1,6 +1,10 @@
 import 'server-only';
 import { getSessionAccessToken } from '@/lib/auth/dal';
-import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import {
+  BackendRequestError,
+  backendFetch,
+  type BackendResult,
+} from '@/lib/http/backend-fetch';
 import type { StudentMember } from '@/lib/domain/student-member';
 import type {
   StudentMemberQuery,
@@ -13,7 +17,8 @@
  * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
  *
  *   GET /api/v1/mngr/user/pagination  (ROLE_ADMIN 전용)
- *   → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
+ *   → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
+ *              grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages }
  *
  * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
  * — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml.
@@ -37,6 +42,7 @@
  */
 
 const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination';
+const STUDENT_MEMBER_BASE_PATH = '/api/v1/mngr/user';
 
 /**
  * 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의
@@ -115,6 +121,25 @@
   return value;
 }
 
+/** 있으면 문자열로, 없거나 비어 있으면 null. 백엔드가 빈 문자열로 "없음"을 표현하기도 한다. */
+function readOptionalString(raw: Record<string, unknown>, key: string): string | null {
+  const value = raw[key];
+  if (typeof value === 'string') {
+    return value.trim() === '' ? null : value;
+  }
+  return typeof value === 'number' ? String(value) : null;
+}
+
+/** 학년·반처럼 숫자로 쓰는 값. 숫자로 읽히지 않으면 null이다(백엔드가 문자열로 준다). */
+function readOptionalNumber(raw: Record<string, unknown>, key: string): number | null {
+  const text = readOptionalString(raw, key);
+  if (text === null) {
+    return null;
+  }
+  const parsed = Number(text);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
 /**
  * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 —
  * 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다).
@@ -125,6 +150,15 @@
  * 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
  * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
  */
+/** 백엔드의 `useYn`은 'Y'/'N' 문자열이다. 값이 없으면 판단하지 않고 null로 둔다. */
+function readUseYn(raw: Record<string, unknown>): boolean | null {
+  const value = readOptionalString(raw, 'useYn');
+  if (value === null) {
+    return null;
+  }
+  return value.toUpperCase() === 'Y';
+}
+
 function toStudentMember(raw: unknown): StudentMember {
   if (!isRecord(raw)) {
     throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
@@ -138,18 +172,20 @@
     memberCode: userId,
     name: readRequiredString(raw, 'userNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    phoneNumber: null,
-    email: null,
+    phoneNumber: readOptionalString(raw, 'userTelno'),
+    email: readOptionalString(raw, 'userEmlAddr'),
     role: STUDENT_ROLE_LABEL,
-    schoolName: null,
-    grade: null,
-    classNumber: null,
+    schoolName: readOptionalString(raw, 'schNm'),
+    grade: readOptionalNumber(raw, 'grade'),
+    classNumber: readOptionalNumber(raw, 'clsNo'),
+    // 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다.
     studentNumber: null,
+    // 보호자 정보와 가입일도 아직 응답에 없다.
     guardianName: null,
     guardianPhoneNumber: null,
     joinedAt: null,
-    birthDate: null,
-    isActive: null,
+    birthDate: readOptionalString(raw, 'birth'),
+    isActive: readUseYn(raw),
   };
 }
 
@@ -277,3 +313,38 @@
 ): Promise<StudentMemberPage> {
   return query.sort === 'name' ? fetchByName(query) : fetchByJoinedAt(query);
 }
+
+/**
+ * 사용여부 변경.
+ *
+ *   PUT /api/v1/mngr/user/{userId}/{useYn}   (ROLE_ADMIN 전용)
+ *
+ * 값과 대상이 모두 **경로에** 실린다 — 본문도 쿼리도 쓰지 않는다.
+ *
+ * ⚠️ 이 엔드포인트는 **성공 시 본문이 비어 온다.** 인터페이스에는 `ApiResponseVO` 반환으로
+ * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버린다
+ * (edupay-backend develop 4d98756의 `MngrUserApiController.updateUserUseYn`). 그래서
+ * `canHaveEmptyBody`로 봉투 없는 2xx를 성공으로 받는다 — 백엔드가 봉투를 돌려주도록 고쳐도
+ * 그대로 동작한다.
+ *
+ * 최종수정자 정보는 보내지 않는다. 매퍼가 `LAST_MDFR_*`를 쓰지만 그 값은 백엔드의
+ * `CrudLogInterceptor`가 UPDATE마다 자동으로 채운다.
+ */
+export async function updateStudentMemberUseYn(
+  userId: string,
+  isActive: boolean
+): Promise<BackendResult<null>> {
+  const accessToken = await getSessionAccessToken();
+
+  return backendFetch<null>(
+    `${STUDENT_MEMBER_BASE_PATH}/${encodeURIComponent(userId)}/${
+      isActive ? 'Y' : 'N'
+    }`,
+    {
+      method: 'PUT',
+      accessToken: accessToken ?? undefined,
+      canHaveEmptyBody: true,
+      canHaveNullData: true,
+    }
+  );
+}
lib/domain/admin-member-form.ts
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
@@ -34,7 +34,14 @@
 export const ADMIN_LOGIN_ID_HELP_TEXT =
   '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요.';
 export const ADMIN_PASSWORD_HELP_TEXT =
-  '영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상';
+  '영어, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상';
+
+/**
+ * 비밀번호가 규칙에 걸렸을 때의 문구. **안내 문구와 글자가 달라야 한다** — 같으면 저장이
+ * 거부돼도 화면의 글자가 하나도 바뀌지 않아 사용자가 실패한 줄 모른다(실제로 그랬다).
+ */
+export const ADMIN_PASSWORD_ERROR_TEXT =
+  '비밀번호가 규칙에 맞지 않습니다. 영어·숫자·특수문자 중 2종류 이상으로 10자리 이상 입력해 주세요.';
 
 
 const LOGIN_ID_MIN_LENGTH = 4;
@@ -141,11 +148,15 @@
 };
 
 /**
- * 비밀번호 조합 종류 수 — 영문 소문자 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다.
- * 시안이 "2종류 이상"만 요구하므로 대문자는 별도 종류로 세지 않는다(있어도 무방하다).
+ * 비밀번호 조합 종류 수 — 영문 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다.
+ *
+ * **대문자도 영문 한 종류로 센다** — 화면이 쓰는 `@fox`의 `characterKinds`가 `/[a-zA-Z]/`로
+ * 판정하므로 여기가 `[a-z]`면 규칙이 갈린다. 실제로 `PASSWORD123`처럼 영문자가 전부 대문자인
+ * 값이 화면은 통과하고 저장만 거부되는 상태였다(오류 문구가 안내 문구와 같아 화면에 아무것도
+ * 드러나지 않았다).
  */
 function countCharacterKinds(password: string): number {
-  const kinds = [/[a-z]/, /\d/, /[^a-zA-Z0-9]/];
+  const kinds = [/[a-zA-Z]/, /\d/, /[^a-zA-Z0-9]/];
   return kinds.filter((pattern) => pattern.test(password)).length;
 }
 
@@ -197,13 +208,13 @@
 
   if (!password) {
     if (options.passwordRequired) {
-      errors.password = ADMIN_PASSWORD_HELP_TEXT;
+      errors.password = ADMIN_PASSWORD_ERROR_TEXT;
     }
   } else if (
     password.length < ADMIN_PASSWORD_POLICY.minLength ||
     countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds
   ) {
-    errors.password = ADMIN_PASSWORD_HELP_TEXT;
+    errors.password = ADMIN_PASSWORD_ERROR_TEXT;
   }
 
   if (!phoneNumber) {
 
lib/domain/common-code-form.ts (added)
+++ lib/domain/common-code-form.ts
@@ -0,0 +1,191 @@
+/**
+ * 코드관리 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
+ *
+ * 시안 네 팝업(공통코드 등록/수정, 상세코드 등록/수정)이 이 파일의 규칙을 공유한다.
+ * **이 파일이 검증의 단일 진실원천이다** — Server Action이 저장 직전에 여기를 거친다.
+ *
+ * 시안의 필수(*) 표시는 상세코드 팝업에만 있지만(코드·코드값의미·정렬번호), 공통코드도
+ * 코드ID·코드명 없이는 저장할 수 없으므로 같은 강도로 검증한다 — 화면 표시와 무관하게
+ * 백엔드가 PK로 쓰는 값이다.
+ *
+ * `CommonCodeFormState`가 이 파일에 있는 이유는 Next.js가 `'use server'` 파일에서 함수가 아닌
+ * 값을 export하는 것을 런타임에 거부하기 때문이다(꾸미기 아이템 폼과 같은 사정).
+ */
+
+const CODE_MAX_LENGTH = 50;
+const NAME_MAX_LENGTH = 100;
+const DESCRIPTION_MAX_LENGTH = 500;
+
+/** 코드값은 영문 대문자·숫자·언더스코어만 받는다 — 코드테이블 키라 공백·한글이 섞이면 곤란하다. */
+const CODE_PATTERN = /^[A-Z0-9_]+$/;
+
+/** 공통코드(그룹) 등록·수정이 실제로 바꿀 수 있는 항목. */
+export type CommonCodeGroupValues = {
+  comCd: string;
+  name: string;
+  description: string;
+};
+
+/** 상세코드 등록·수정이 실제로 바꿀 수 있는 항목. */
+export type CommonCodeDetailValues = {
+  /** 소속 그룹. 화면에서는 읽기 전용이지만 저장 대상이라 값으로 다룬다. */
+  comCd: string;
+  comDtlCd: string;
+  /** 시안의 "코드값의미" — 백엔드 `cdNm`이다. */
+  name: string;
+  description: string;
+  sortSeq: number;
+};
+
+export type CommonCodeFormErrors = Partial<
+  Record<
+    keyof CommonCodeGroupValues | keyof CommonCodeDetailValues,
+    string
+  >
+>;
+
+export type ValidationResult<T> =
+  | { ok: true; values: T }
+  | { ok: false; errors: CommonCodeFormErrors };
+
+export type CommonCodeFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: CommonCodeFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_COMMON_CODE_FORM_STATE: CommonCodeFormState = {
+  status: 'idle',
+};
+
+/** 코드값 공통 검증 — 그룹의 `comCd`와 상세의 `comDtlCd`가 같은 규칙을 쓴다. */
+function validateCode(
+  raw: string,
+  label: string
+): { value: string; error?: string } {
+  // 코드는 대문자로 정규화한다 — 소문자로 저장되면 조회 조건과 어긋난다.
+  const value = raw.trim().toUpperCase();
+
+  if (!value) {
+    return { value, error: `${label}를 입력해 주세요.` };
+  }
+  if (value.length > CODE_MAX_LENGTH) {
+    return {
+      value,
+      error: `${label}는 ${CODE_MAX_LENGTH}자 이내로 입력해 주세요.`,
+    };
+  }
+  if (!CODE_PATTERN.test(value)) {
+    return { value, error: `${label}는 영문 대문자·숫자·_만 사용할 수 있습니다.` };
+  }
+  return { value };
+}
+
+function validateName(raw: string, label: string): { value: string; error?: string } {
+  const value = raw.trim();
+
+  if (!value) {
+    return { value, error: `${label}을 입력해 주세요.` };
+  }
+  if (value.length > NAME_MAX_LENGTH) {
+    return {
+      value,
+      error: `${label}은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`,
+    };
+  }
+  return { value };
+}
+
+function validateDescription(raw: string): { value: string; error?: string } {
+  const value = raw.trim();
+
+  if (value.length > DESCRIPTION_MAX_LENGTH) {
+    return {
+      value,
+      error: `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`,
+    };
+  }
+  return { value };
+}
+
+/** 시안 공통코드 등록/수정 — 수정은 코드ID가 읽기 전용이라 값이 폼이 아니라 경로에서 온다. */
+export function validateCommonCodeGroup(
+  values: CommonCodeGroupValues
+): ValidationResult<CommonCodeGroupValues> {
+  const errors: CommonCodeFormErrors = {};
+
+  const code = validateCode(values.comCd, '코드ID');
+  if (code.error) {
+    errors.comCd = code.error;
+  }
+
+  const name = validateName(values.name, '코드ID명');
+  if (name.error) {
+    errors.name = name.error;
+  }
+
+  const description = validateDescription(values.description);
+  if (description.error) {
+    errors.description = description.error;
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: {
+      comCd: code.value,
+      name: name.value,
+      description: description.value,
+    },
+  };
+}
+
+/** 시안 상세코드 등록/수정. */
+export function validateCommonCodeDetail(
+  values: CommonCodeDetailValues
+): ValidationResult<CommonCodeDetailValues> {
+  const errors: CommonCodeFormErrors = {};
+
+  const group = validateCode(values.comCd, '코드ID');
+  if (group.error) {
+    // 상세코드 팝업의 코드ID는 좌측 선택에서 오는 읽기 전용 값이라, 여기가 비었다는 것은
+    // 공통코드를 고르지 않고 저장이 시도됐다는 뜻이다.
+    errors.comCd = '공통코드를 먼저 선택해 주세요.';
+  }
+
+  const code = validateCode(values.comDtlCd, '코드');
+  if (code.error) {
+    errors.comDtlCd = code.error;
+  }
+
+  const name = validateName(values.name, '코드값의미');
+  if (name.error) {
+    errors.name = name.error;
+  }
+
+  const description = validateDescription(values.description);
+  if (description.error) {
+    errors.description = description.error;
+  }
+
+  if (!Number.isInteger(values.sortSeq) || values.sortSeq < 1) {
+    errors.sortSeq = '정렬번호는 1 이상의 숫자로 입력해 주세요.';
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: {
+      comCd: group.value,
+      comDtlCd: code.value,
+      name: name.value,
+      description: description.value,
+      sortSeq: values.sortSeq,
+    },
+  };
+}
 
lib/domain/common-code-query.ts (added)
+++ lib/domain/common-code-query.ts
@@ -0,0 +1,137 @@
+/**
+ * 코드관리 화면(SYS_COD_001)의 URL 조건 — 순수 규칙만 담는다(next/react 의존 없음).
+ *
+ * 이 화면은 목록이 둘이라 상태가 셋이다. **선택된 공통코드(`comCd`)**, 공통코드 목록의 검색·
+ * 페이지, 상세코드 목록의 페이지. 셋 다 URL이 소유한다 — 다른 목록 화면과 같은 규칙이고,
+ * 새로고침·뒤로가기·링크 공유가 그대로 동작한다.
+ *
+ * **선택이 바뀌면 상세 페이지는 1로 돌아간다** — 다른 그룹의 3페이지는 의미가 없다.
+ * 그 규칙은 `buildCommonCodeHref`가 강제한다(호출부가 잊어도 어긋나지 않게).
+ *
+ * 페이징·검색을 URL에 두면서도 백엔드에는 넘기지 않는다 — 백엔드가 두 목록 모두 전체를
+ * 반환하고 검색도 완전일치라, 자르고 거르는 일은 서버 컴포넌트가 한다(Repository 주석 참조).
+ */
+
+/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
+export const COMMON_CODES_PATH = '/system/codes';
+
+/** 검색 대상 — 시안(SYS_COD_001 ①) "코드명 / 코드ID". */
+export type CommonCodeSearchField = 'name' | 'comCd';
+
+export const COMMON_CODE_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
+  value: CommonCodeSearchField;
+  label: string;
+}> = [
+  { value: 'name', label: '코드명' },
+  { value: 'comCd', label: '코드ID' },
+];
+
+export const DEFAULT_COMMON_CODE_SEARCH_FIELD: CommonCodeSearchField = 'name';
+
+/** 시안의 두 목록은 한 화면에 나란히 서므로 페이지 크기를 고르는 자리가 없다 — 고정값이다. */
+export const COMMON_CODE_PAGE_SIZE = 10;
+export const COMMON_CODE_DETAIL_PAGE_SIZE = 10;
+
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type CommonCodeQuery = {
+  /** 선택된 공통코드. 아직 고르지 않았으면 null이고, 화면이 첫 행으로 채운다. */
+  comCd: string | null;
+  searchField: CommonCodeSearchField;
+  keyword: string;
+  /** 공통코드 목록의 페이지. */
+  page: number;
+  /** 상세코드 목록의 페이지. */
+  detailPage: number;
+};
+
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string | undefined {
+  const value = params[key];
+  return Array.isArray(value) ? value[0] : value;
+}
+
+function readPage(params: RawSearchParams, key: string): number {
+  const value = Number(readParam(params, key));
+  return Number.isInteger(value) && value > 0 ? value : DEFAULT_PAGE;
+}
+
+function isSearchField(
+  value: string | undefined
+): value is CommonCodeSearchField {
+  return (
+    value !== undefined &&
+    COMMON_CODE_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+/**
+ * URL의 searchParams를 검증된 `CommonCodeQuery`로 정규화한다. searchParams는 사용자가 임의로
+ * 조작할 수 있는 값이라 신뢰하지 않는다 — 허용 목록을 벗어나면 기본값으로 떨어진다.
+ *
+ * `comCd`만은 허용 목록을 여기서 확인할 수 없다(코드 목록이 서버에 있다) — 존재 여부는 화면이
+ * 조회 결과와 맞춰 보고 없으면 첫 행으로 대체한다.
+ */
+export function parseCommonCodeQuery(
+  searchParams: RawSearchParams
+): CommonCodeQuery {
+  const searchFieldRaw = readParam(searchParams, 'searchField');
+  const comCd = readParam(searchParams, 'comCd')?.trim();
+
+  return {
+    comCd: comCd ? comCd.slice(0, 50) : null,
+    searchField: isSearchField(searchFieldRaw)
+      ? searchFieldRaw
+      : DEFAULT_COMMON_CODE_SEARCH_FIELD,
+    keyword: (readParam(searchParams, 'keyword') ?? '')
+      .trim()
+      .slice(0, MAX_KEYWORD_LENGTH),
+    page: readPage(searchParams, 'page'),
+    detailPage: readPage(searchParams, 'detailPage'),
+  };
+}
+
+/**
+ * `CommonCodeQuery`(+ 부분 override)를 링크로 직렬화한다. `parseCommonCodeQuery`의 역연산이며
+ * 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다.
+ *
+ * **공통코드 선택이 바뀌면 상세 페이지를 1로 되돌린다** — 호출부가 잊어도 어긋나지 않도록
+ * 여기서 강제한다(override로 detailPage를 함께 준 경우는 그 값을 존중한다).
+ */
+export function buildCommonCodeHref(
+  query: CommonCodeQuery,
+  overrides: Partial<CommonCodeQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+
+  if (
+    overrides.comCd !== undefined &&
+    overrides.comCd !== query.comCd &&
+    overrides.detailPage === undefined
+  ) {
+    merged.detailPage = DEFAULT_PAGE;
+  }
+
+  const params = new URLSearchParams();
+
+  if (merged.comCd) {
+    params.set('comCd', merged.comCd);
+  }
+  if (merged.searchField !== DEFAULT_COMMON_CODE_SEARCH_FIELD) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+  if (merged.detailPage !== DEFAULT_PAGE) {
+    params.set('detailPage', String(merged.detailPage));
+  }
+
+  const queryString = params.toString();
+  return queryString ? `${COMMON_CODES_PATH}?${queryString}` : COMMON_CODES_PATH;
+}
 
lib/domain/common-code.ts (added)
+++ lib/domain/common-code.ts
@@ -0,0 +1,117 @@
+/**
+ * 공통코드 도메인 — 순수 데이터 표현, 외부 의존 없음.
+ *
+ * 백엔드 `TB_SYS_COM_CD`(그룹)와 `TB_SYS_COM_CD_DTL`(상세)의 두 층이며, 둘 다
+ * `/api/v1/mngr/code/**`(ROLE_ADMIN)로 읽고 쓴다.
+ *
+ * 이 파일은 두 종류의 소비자를 함께 섬긴다.
+ *   - 코드관리 화면(SYS_COD_001): 그룹·상세를 편집한다 → `CommonCodeGroup`·`CommonCodeDetail`
+ *   - 다른 화면의 선택지: 상세코드를 `{code,label}`로만 쓴다 → `CommonCode`
+ */
+
+/** 선택지로 쓸 때의 최소 표현. 화면이 코드 편집에 관심이 없을 때 쓴다. */
+export type CommonCode = {
+  /** 백엔드 `comDtlCd` — 저장·전송에 쓰는 코드값. */
+  code: string;
+  /** 백엔드 `cdNm` — 화면에 보이는 이름. */
+  label: string;
+};
+
+/** 코드 그룹 ID — 화면이 문자열을 직접 적지 않도록 여기 모은다. */
+export const CODE_GROUP = {
+  /** 꾸미기 아이템 카테고리. */
+  decorationItemCategory: 'ITEM_CATE_CD',
+} as const;
+
+/** 값이 없는 항목의 화면 표기. */
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+/**
+ * 공통코드(그룹) — 시안의 "공통코드 목록" 한 줄.
+ *
+ * `TB_SYS_COM_CD`의 컬럼은 이 셋이 전부다(+감사 컬럼). 시안 등록 팝업의 "분류코드"에
+ * 해당하는 컬럼은 **없다** — 그 셀렉트는 코드ID 접두사를 채워 주는 입력 보조일 뿐이고
+ * 저장되는 값은 `comCd` 하나다(사용자 확정 사항).
+ */
+export type CommonCodeGroup = {
+  /** 백엔드 `comCd` — PK이자 화면의 "코드ID". 상세코드를 묶는 키다. */
+  comCd: string;
+  /** 백엔드 `cdNm` — 화면의 "코드명". */
+  name: string;
+  /** 백엔드 `cdExpln` — 화면의 "코드설명". */
+  description: string | null;
+  /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */
+  createdAt: string | null;
+};
+
+/**
+ * 상세코드 — 시안의 "상세코드 목록" 한 줄.
+ *
+ * ⚠️ `description`은 지금 **항상 null이다** — 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지
+ * 않는다. 등록 INSERT에서도 빠져 있어 수정으로만 저장된다(Repository 주석 참조).
+ */
+export type CommonCodeDetail = {
+  /** 백엔드 `comCd` — 이 상세코드가 속한 그룹. */
+  comCd: string;
+  /** 백엔드 `comDtlCd` — 그룹 안에서의 코드값. 화면의 "상세코드ID". */
+  comDtlCd: string;
+  /** 백엔드 `cdNm` — 화면의 "상세코드명"이자 등록 팝업의 "코드값의미". */
+  name: string;
+  /** 백엔드 `dtlCdExpln`. */
+  description: string | null;
+  /** 백엔드 `sortSeq` — 등록 팝업의 "정렬번호"이자 목록의 "번호". */
+  sortSeq: number;
+  /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */
+  createdAt: string | null;
+};
+
+/**
+ * 코드ID 접두사(시안의 "분류코드") — 저장되는 값이 아니라 코드ID를 지을 때의 작명 규칙이다.
+ * 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라 그 규칙을 화면이 거들게 한다.
+ *
+ * 백엔드에 분류 컬럼이 생기면 이 상수 대신 그 코드 목록을 쓰면 된다.
+ */
+export const CODE_ID_PREFIXES: readonly string[] = ['CMS', 'SYS', 'FSC', 'CST'];
+
+export const DEFAULT_CODE_ID_PREFIX = CODE_ID_PREFIXES[0];
+
+/**
+ * 코드ID에서 접두사를 읽는다 — 앞 3자가 아는 접두사면 그것을, 아니면 기본값을 돌려준다.
+ * 수정 팝업이 기존 코드ID로 셀렉트의 초기값을 정할 때 쓴다.
+ */
+export function readCodeIdPrefix(comCd: string): string {
+  const head = comCd.slice(0, 3).toUpperCase();
+  return CODE_ID_PREFIXES.includes(head) ? head : DEFAULT_CODE_ID_PREFIX;
+}
+
+/**
+ * 접두사를 바꿔 끼운 코드ID를 만든다. 기존 값이 아는 접두사로 시작하면 그 자리를 갈아 끼우고,
+ * 아니면 앞에 덧붙인다 — 사용자가 이미 적어 둔 일련번호를 지우지 않기 위해서다.
+ */
+export function applyCodeIdPrefix(comCd: string, prefix: string): string {
+  const rest = CODE_ID_PREFIXES.includes(comCd.slice(0, 3).toUpperCase())
+    ? comCd.slice(3)
+    : comCd;
+  return `${prefix}${rest}`;
+}
+
+export function formatOptionalText(value: string | null | undefined): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : value;
+}
+
+/** 코드값 → 이름. 목록에 없는 코드는 코드값 자체를 보여준다(이름을 지어내지 않는다). */
+export function formatCommonCode(
+  codes: readonly CommonCode[],
+  code: string
+): string {
+  return codes.find((item) => item.code === code)?.label ?? code;
+}
+
+export function isKnownCommonCode(
+  codes: readonly CommonCode[],
+  code: string
+): boolean {
+  return codes.some((item) => item.code === code);
+}
lib/domain/decoration-item-form.ts
--- lib/domain/decoration-item-form.ts
+++ lib/domain/decoration-item-form.ts
@@ -13,8 +13,8 @@
  * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를 거친다.
  */
 
+import { isKnownCommonCode, type CommonCode } from '@/lib/domain/common-code';
 import {
-  DECORATION_ITEM_CATEGORIES,
   DECORATION_ITEM_TYPE_OPTIONS,
   type DecorationItemType,
 } from '@/lib/domain/decoration-item';
@@ -69,10 +69,6 @@
   return DECORATION_ITEM_TYPE_OPTIONS.some((option) => option.value === value);
 }
 
-function isDecorationItemCategoryCode(value: string): boolean {
-  return DECORATION_ITEM_CATEGORIES.some((category) => category.code === value);
-}
-
 /**
  * 등록·수정 공통 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
  *
@@ -83,6 +79,7 @@
  */
 function validateEditableValues(
   values: DecorationItemEditableValues,
+  categories: readonly CommonCode[],
   errors: DecorationItemFormErrors
 ): DecorationItemEditableValues {
   const name = values.name.trim();
@@ -98,7 +95,7 @@
     errors.name = `아이템명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
   }
 
-  if (!isDecorationItemCategoryCode(values.categoryCode)) {
+  if (!isKnownCommonCode(categories, values.categoryCode)) {
     errors.categoryCode = '카테고리를 선택해 주세요.';
   }
 
@@ -123,12 +120,18 @@
   return { ...values, name, description };
 }
 
-/** 시안 ADM_ITM_102_p — 등록 검증. */
+/**
+ * 시안 ADM_ITM_102_p — 등록 검증.
+ *
+ * 허용 카테고리는 상수가 아니라 **인자로 받는다** — 값이 백엔드 공통코드(`ITEM_CATE_CD`)에서
+ * 오므로 이 파일이 알 수 없고, 알아서도 안 된다(domain 계층은 통신을 하지 않는다).
+ */
 export function validateDecorationItemCreate(
-  values: DecorationItemEditableValues
+  values: DecorationItemEditableValues,
+  categories: readonly CommonCode[]
 ): ValidationResult<DecorationItemEditableValues> {
   const errors: DecorationItemFormErrors = {};
-  const editable = validateEditableValues(values, errors);
+  const editable = validateEditableValues(values, categories, errors);
 
   if (Object.keys(errors).length > 0) {
     return { ok: false, errors };
@@ -139,7 +142,8 @@
 
 /** 시안 ADM_ITM_103_p — 수정 검증. 아이템ID는 읽기 전용이라 검증 대상이 아니다. */
 export function validateDecorationItemUpdate(
-  values: DecorationItemEditableValues
+  values: DecorationItemEditableValues,
+  categories: readonly CommonCode[]
 ): ValidationResult<DecorationItemEditableValues> {
-  return validateDecorationItemCreate(values);
+  return validateDecorationItemCreate(values, categories);
 }
lib/domain/decoration-item.ts
--- lib/domain/decoration-item.ts
+++ lib/domain/decoration-item.ts
@@ -26,27 +26,6 @@
 
 export const DEFAULT_DECORATION_ITEM_TYPE: DecorationItemType = 'individual';
 
-/**
- * 카테고리 — **임시 값이다.** 백엔드는 공통코드테이블(`TB_SYS_COM_CD_DTL`, `COM_CD='ITEM_CATE_CD'`)을
- * 조인해 `itemCateNm`을 내려주지만 그 코드가 아직 정비되지 않아, 사용자 지시에 따라 프론트에서
- * 임의 코드로 개발한다(시안 등록 팝업의 예시값 계절/축하/시즌을 그대로 씀).
- *
- * **백엔드에 코드가 추가되면 이 상수를 지우고 `GET /api/v1/common/code/ITEM_CATE_CD`(인증 불필요)
- * 응답으로 교체한다.** 그때까지 저장되는 `itemCateCd`는 여기 정의된 임시 코드라, 실제 코드 체계가
- * 정해지면 기존 데이터의 코드값 마이그레이션이 필요하다.
- */
-export const DECORATION_ITEM_CATEGORIES: ReadonlyArray<{
-  code: string;
-  label: string;
-}> = [
-  { code: 'CATE01', label: '계절' },
-  { code: 'CATE02', label: '축하' },
-  { code: 'CATE03', label: '시즌' },
-];
-
-export const DEFAULT_DECORATION_ITEM_CATEGORY_CODE =
-  DECORATION_ITEM_CATEGORIES[0].code;
-
 export type DecorationItem = {
   /**
    * 백엔드 `itemSn` — 자동증가 PK이자 화면의 "아이템ID"로 그대로 노출하는 값(사용자 확정 사항).
@@ -56,12 +35,9 @@
   itemSn: number;
   itemType: DecorationItemType;
   name: string;
-  /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값. */
+  /** 백엔드 `itemCateCd` — 저장·전송에 쓰는 코드값(공통코드 `ITEM_CATE_CD`의 상세코드). */
   categoryCode: string;
-  /**
-   * 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null로 온다
-   * (지금은 임시 코드를 쓰므로 대개 null이다 — 화면은 `formatDecorationItemCategory`로 보완한다).
-   */
+  /** 백엔드 `itemCateNm` — 공통코드테이블 조인 결과. 코드가 코드테이블에 없으면 null이다. */
   categoryName: string | null;
   /** 백엔드 `itemAmount` — 오픈 가능한 포인트. */
   points: number;
@@ -82,9 +58,9 @@
 /** 값이 없는 항목의 화면 표기. */
 export const EMPTY_FIELD_PLACEHOLDER = '-';
 
-/** 사용여부 → 화면 표기(시안: O/X). */
+/** 사용여부 → 화면 표기(시안 ADM_ITM_101: 점 + "사용"/"미사용"). */
 export function formatDecorationItemActiveLabel(isActive: boolean): string {
-  return isActive ? 'O' : 'X';
+  return isActive ? '사용' : '미사용';
 }
 
 /** 포인트 → 화면 표기(시안: "100 P" 형태). */
@@ -93,19 +69,11 @@
 }
 
 /**
- * 카테고리 표기 — 백엔드가 코드테이블에서 찾은 이름을 우선 쓰고, 없으면 프론트 임시 목록에서
- * 찾고, 그것도 없으면 코드값 자체를 보여준다. 임시 코드 단계에서는 두 번째 경로가 주로 쓰이고,
- * 백엔드 코드가 정비되면 자연스럽게 첫 번째 경로로 넘어간다.
+ * 카테고리 표기 — 목록 SQL이 코드테이블을 조인해 내려 준 이름을 쓴다. 코드가 코드테이블에서
+ * 지워졌거나 값 자체가 비었을 때만 코드값·`-`로 떨어진다(이름을 지어내지 않는다).
  */
 export function formatDecorationItemCategory(item: DecorationItem): string {
-  if (item.categoryName) {
-    return item.categoryName;
-  }
-
-  const known = DECORATION_ITEM_CATEGORIES.find(
-    (category) => category.code === item.categoryCode
-  );
-  return known?.label ?? item.categoryCode ?? EMPTY_FIELD_PLACEHOLDER;
+  return item.categoryName || item.categoryCode || EMPTY_FIELD_PLACEHOLDER;
 }
 
 /** 수정일시 표기 — 백엔드가 이미 `YYYY-MM-DD`로 포맷해 주므로 그대로 쓰고 null만 보완한다. */
 
lib/domain/phone-number.ts (added)
+++ lib/domain/phone-number.ts
@@ -0,0 +1,82 @@
+/**
+ * 전화번호 표기.
+ *
+ * 백엔드는 하이픈 없이 숫자만 저장한다(예: `01011233400`). 시안은 `010-1123-3400`으로
+ * 끊어 보여주므로 화면에 나가기 직전에 여기서 끊는다 — 저장된 값 자체는 건드리지 않는다.
+ *
+ * 도메인마다 두지 않고 한 파일에 모은 이유는 학생·관리자·보호자가 같은 규칙을 써야 하기
+ * 때문이다.
+ */
+
+/** 자릿수별 묶음 규칙. 앞자리가 `02`인 서울 번호만 지역번호가 두 자리다. */
+function splitGroups(digits: string): string[] | null {
+  if (digits.startsWith('02')) {
+    if (digits.length === 9) {
+      return [digits.slice(0, 2), digits.slice(2, 5), digits.slice(5)];
+    }
+    if (digits.length === 10) {
+      return [digits.slice(0, 2), digits.slice(2, 6), digits.slice(6)];
+    }
+    return null;
+  }
+
+  if (digits.length === 11) {
+    return [digits.slice(0, 3), digits.slice(3, 7), digits.slice(7)];
+  }
+  if (digits.length === 10) {
+    return [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6)];
+  }
+  // 지역번호 없이 저장된 국번+번호.
+  if (digits.length === 8) {
+    return [digits.slice(0, 4), digits.slice(4)];
+  }
+
+  return null;
+}
+
+/**
+ * 하이픈을 넣어 돌려준다.
+ *
+ * - 이미 하이픈이 들어 있으면 그대로 둔다 — 백엔드가 형식을 갖춰 준 값을 다시 끊지 않는다.
+ * - 자릿수가 알려진 규칙에 맞지 않으면 **원본을 그대로** 돌려준다. 임의로 끊으면 잘못된
+ *   번호를 그럴듯하게 보여주게 되므로, 판단이 서지 않을 때는 손대지 않는다.
+ * - 값이 없으면 null 그대로다. `-` 표기는 호출부의 `formatOptionalValue`가 맡는다.
+ */
+export function formatPhoneNumber(value: string | null): string | null {
+  if (value === null) {
+    return null;
+  }
+
+  const trimmed = value.trim();
+  if (trimmed === '' || trimmed.includes('-')) {
+    return trimmed === '' ? null : trimmed;
+  }
+
+  const digits = trimmed.replace(/\D/g, '');
+  const groups = digits.length === trimmed.length ? splitGroups(digits) : null;
+
+  return groups === null ? trimmed : groups.join('-');
+}
+
+/**
+ * 휴대전화 앞자리 목록 — `FoxPhoneNumber`의 앞자리 셀렉트에 넘긴다.
+ *
+ * **넘기지 않으면 그 셀렉트에 고를 것이 하나도 없다**(@fox의 기본값이 빈 배열이다). 그때도 칸에는
+ * placeholder "010"이 떠서 고른 것처럼 보이는데 값은 빈 문자열이라, 가운데·끝자리를 다 채워도
+ * 8자리만 제출돼 "휴대전화 번호를 정확히 입력해 주세요."로 막힌다.
+ */
+export const MOBILE_PHONE_PREFIXES: readonly string[] = [
+  '010',
+  '011',
+  '016',
+  '017',
+  '018',
+  '019',
+];
+
+/**
+ * 앞자리 기본값. **화면이 이 값으로 시작해야 한다** — 셀렉트를 비워 두면 placeholder "010"이
+ * 고른 것처럼 보이는데 값은 비어 있어, 사용자가 앞자리를 건드리지 않고 뒷자리만 채우면 8자리가
+ * 제출된다(실제로 그래서 등록이 막혔다).
+ */
+export const DEFAULT_MOBILE_PHONE_PREFIX = MOBILE_PHONE_PREFIXES[0];
lib/domain/student-member.ts
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
@@ -52,15 +52,19 @@
 
 /**
  * "학년/반/번호" 합성 표기. 표와 조회 팝업이 같은 규칙을 쓰도록 한 곳에 둔다.
- * 세 값 중 하나라도 없으면 부분 문장("1학년 -반 -번")을 만들지 않고 통째로 `-`로 표기한다 —
- * 셋이 함께여야 의미가 성립하는 한 덩어리이기 때문이다.
+ *
+ * **있는 값만 이어 붙인다.** 백엔드 `TB_COM_USER`에는 GRADE·CLS_NO만 있고 학생 번호 컬럼이
+ * 아예 없어(코드베이스 전체 VO가 그 둘만 쓴다) 번호는 영영 오지 않는다. 셋이 다 있어야
+ * 표기하던 종전 규칙으로는 실제로 오는 학년·반까지 함께 묻혔다.
+ *
+ * 하나도 없으면 `-`다.
  */
 export function formatGradeClassNumber(member: StudentMember): string {
-  const { grade, classNumber, studentNumber } = member;
+  const parts = [
+    member.grade === null ? null : `${member.grade}학년`,
+    member.classNumber === null ? null : `${member.classNumber}반`,
+    member.studentNumber === null ? null : `${member.studentNumber}번`,
+  ].filter((part): part is string => part !== null);
 
-  if (grade === null || classNumber === null || studentNumber === null) {
-    return EMPTY_FIELD_PLACEHOLDER;
-  }
-
-  return `${grade}학년 ${classNumber}반 ${studentNumber}번`;
+  return parts.length > 0 ? parts.join(' ') : EMPTY_FIELD_PLACEHOLDER;
 }
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -57,10 +57,13 @@
    * form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다.
    *
    * 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute
-   * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`,
-   * 아이템 등록·수정 `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지
-   * 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로,
-   * 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다.
+   * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, 아이템 **등록**
+   * `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 않아 **전 필드가
+   * null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, 엔드포인트가 어느
+   * 쪽인지 확인하고 맞는 형식을 골라야 한다.
+   *
+   * ⚠️ **같은 도메인 안에서도 갈린다** — 아이템 **수정**은 `@RequestBody`(JSON)다. 등록만 보고
+   * 도메인 전체를 form으로 단정하면 수정이 조용히 깨진다.
    *
    * `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다).
    */
@@ -89,6 +92,20 @@
    * null이 직렬화됨).
    */
   canHaveNullData?: boolean;
+  /**
+   * 이 호출은 성공 시 **본문이 비어 올 수 있다.** 봉투를 파싱하지 않고 2xx를 그대로 성공으로
+   * 본다(`data`는 null).
+   *
+   * 백엔드가 봉투를 안 주는 엔드포인트가 실제로 있다 — 사용여부 변경
+   * (`PUT /api/v1/mngr/user/{userId}/{useYn}`)은 인터페이스에 `ApiResponseVO` 반환으로
+   * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서,
+   * `@RestController` + `void` + `HttpServletResponse` 조합상 본문 없는 200이 나간다
+   * (edupay-backend develop 4d98756 확인).
+   *
+   * 백엔드가 봉투를 돌려주도록 고쳐도 이 플래그를 그대로 둘 수 있다 — 본문이 있으면 아래에서
+   * 정상적으로 파싱한다.
+   */
+  canHaveEmptyBody?: boolean;
   /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */
   timeoutMs?: number;
 };
@@ -268,6 +285,30 @@
     return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
   }
 
+  // 본문 없는 2xx를 허용한 호출은 먼저 글자를 읽어 비었는지 본다 — 비어 있으면 `response.json()`이
+  // 파싱 오류를 던지므로 그 전에 갈라야 한다.
+  if (init.canHaveEmptyBody) {
+    let raw: string;
+    try {
+      raw = await response.text();
+    } catch (error) {
+      return communicationError('응답 읽기 실패', error);
+    }
+
+    if (raw.trim() === '') {
+      return { ok: true, data: null as T };
+    }
+
+    try {
+      const parsed = JSON.parse(raw) as BackendEnvelope<T>;
+      return parsed.success
+        ? { ok: true, data: parsed.data as T }
+        : { ok: false, code: parsed.code, message: parsed.message };
+    } catch (error) {
+      return communicationError('응답 파싱 실패', error);
+    }
+  }
+
   let envelope: BackendEnvelope<T>;
   try {
     envelope = (await response.json()) as BackendEnvelope<T>;
Add a comment
List