임동욱 임동욱 08-19
merge: hub — @fox 툴팁·팝오버 4종, 아이템 등록 무반응 수정
@48daf0ebe78a71ceb7b106351714366d96573e09
 
@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-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-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
@@ -22,6 +22,7 @@
 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";
@@ -35,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";
@@ -60,6 +62,8 @@
 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
@@ -16,6 +16,7 @@
 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";
@@ -24,6 +25,7 @@
 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";
@@ -64,6 +66,8 @@
 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";
@@ -707,6 +711,116 @@
           <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>
     </>
   );
 }
@@ -3893,4 +4007,182 @@
       },
     ],
   },
+  {
+    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/_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-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-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
@@ -17,6 +17,7 @@
 @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";
@@ -24,6 +25,7 @@
 @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";
@@ -59,6 +61,8 @@
 @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";
next.config.ts
--- next.config.ts
+++ next.config.ts
@@ -20,6 +20,18 @@
 ];
 
 const nextConfig: NextConfig = {
+  experimental: {
+    serverActions: {
+      // 이미지·첨부파일은 Server Action **본문에 실려** 온다(브라우저가 백엔드를 직접 부르지
+      // 않으므로 — 토큰이 httpOnly 세션 안에만 있다). 기본값 1MB로는 웬만한 이미지가 요청
+      // 단계에서 413으로 잘리는데, 그러면 **액션 본문이 실행조차 되지 않아** `useActionState`가
+      // idle 그대로다 — 화면에는 오류도 토스트도 없이 폼만 비워져 "아무 반응 없음"으로 보인다.
+      // 백엔드는 `CommonsMultipartResolver`가 100MB까지 받지만, 이 본문은 Next 서버 메모리에
+      // 통째로 올라가므로 관리 화면에 필요한 만큼만 연다.
+      bodySizeLimit: '10mb',
+    },
+  },
+
   // SCSS 로드 경로 — 프로젝트 루트를 Sass의 loadPaths에 넣어 `@use "@fox/styles/abstracts"`
   // 같은 루트 기준 절대 경로가 해석되게 한다. 이게 없으면 컴포넌트마다 `../../../` 상대
   // 경로를 써야 해서 @fox 폴더를 다른 프로젝트로 통째로 복사할 수 없다(포터블 요건).
Add a comment
List