임동욱 임동욱 08-12
feat: FoxButton 추가 — 로직·구조와 시안 디자인 적용
Figma 시안(관리자페이지 node 390:439)을 토큰만으로 옮겼다. 컴파일된 CSS의 토큰 참조
50개, 원시 hex/px/rem 리터럴 0개다.

시안에서 확인한 것(추측하지 않음): gap은 lg·md·sm이 gap-2이고 xsm만 gap-1이다.
primary에는 테두리가 없고 secondary·default만 border-1을 갖는다. disabled는 계열과
무관하게 테두리가 사라진다(시안에서 disabled-border 미사용). 높이만 고정이고 좌우
padding만 지정돼 너비는 가변이다.

토큰이 아닌 코드 세 가지를 사용자 승인 후 넣었다.
- line-height: 1 / letter-spacing: -0.025em — 시안 텍스트 스타일 label/*-strong의 값이나
  Figma에서 변수로 export되지 않아 토큰이 없다. 네 크기가 같은 값이라 규칙 하나로 끝난다.
- box-sizing: border-box — 높이 고정 + secondary·default의 1px 테두리 때문에 필수다.
  없으면 계열별 바깥 높이가 2px 어긋난다.
- cursor·white-space·flex-shrink — 구조·상호작용 규칙. 버튼 스코프에만 적용한다.

모양 규칙은 @fox/styles/_fox-button.scss가 소유하고 module이 @use로 끌어올린다 —
클래스명이 모듈 스코프로 감싸져 전역 충돌이 없고, index.scss는 이 파티셜을 포함하지
않아 CSS가 중복 출력되지 않는다.

아이콘은 계열별 색을 토큰으로 입히기 위해 ReactNode로 받는다(currentColor 전제).
크기는 슬롯이 form-icon-* 토큰으로 정한다.

error 계열은 시안 미정이라 클래스를 비워 뒀다.

Co-Authored-By: Claude Opus 5 
@da813ceeac1b27de48b84bf348c3bdd4b65b9c6b
 
@fox/core/components/fox-button/fox-button.module.scss (added)
+++ @fox/core/components/fox-button/fox-button.module.scss
@@ -0,0 +1,9 @@
+// FoxButton의 모양 규칙은 디자인 시스템 쪽(`@fox/styles/_fox-button.scss`)이 소유한다.
+// 여기서는 그것을 CSS Module로 끌어올리기만 한다 — `@use`가 그 파일의 CSS를 이 모듈의
+// 컴파일 단위에 포함시키므로, 클래스명이 모듈 스코프로 감싸지고 컴포넌트에서
+// `styles.button`처럼 쓸 수 있다. 전역 이름 충돌이 생기지 않는다.
+//
+// ⚠️ 이 파티셜을 다른 모듈에서 또 `@use`하면 CSS가 그 모듈에도 복제된다. 소비처는
+// 이 파일 하나로 유지한다.
+
+@use "@fox/styles/fox-button";
 
@fox/core/components/fox-button/fox-button.tsx (added)
+++ @fox/core/components/fox-button/fox-button.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+import styles from "./fox-button.module.scss";
+
+export type FoxButtonType = "primary" | "secondary" | "default" | "error";
+export type FoxButtonSize = "lg" | "md" | "sm" | "xsm";
+
+export interface FoxButtonProps {
+  /** 시각 계열. 디자인은 Figma 시안(node 390-439) 기준으로 추후 적용한다. */
+  type?: FoxButtonType;
+  size: FoxButtonSize;
+  /** 네이티브 `type` 속성. prop 이름의 `type`이 시각 계열을 쓰므로 분리했다. */
+  htmlType?: "button" | "submit" | "reset";
+  /** 배치(마진·그리드) 조정용. 디자인 값 주입은 금지 — 모양이 달라야 하면 `type`을 추가한다. */
+  className?: string;
+  /**
+   * 아이콘 엘리먼트. 계열별 색은 슬롯이 `color`로 내려주므로 **`currentColor`로 그린
+   * SVG**를 넣어야 시안대로 물든다(primary는 흰색, secondary는 파랑 …).
+   * 크기는 슬롯이 `form-icon-*` 토큰으로 정하므로 아이콘 자체에 크기를 두지 않아도 된다.
+   */
+  leadingIcon?: ReactNode;
+  trailingIcon?: ReactNode;
+  label: string;
+  disabled?: boolean;
+  /** 참이면 아무것도 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 호출부가 소유하는 상태다 — 버튼은 이 값을 읽기만 하고 스스로 켜지 않는다. */
+  loading?: boolean;
+  fullWidth?: boolean;
+  onAction?: () => void;
+  /** 포인터가 올라오면 `true`, 벗어나면 `false`. */
+  onHoverChange?: (hovered: boolean) => void;
+  ref?: Ref<HTMLButtonElement>;
+}
+
+/**
+ * variant마다 클래스를 `Record`로 고정한다 — 계열을 추가하면 여기 항목을 빠뜨릴 수 없다
+ * (타입 에러). 모양 규칙은 `@fox/styles/_fox-button.scss`가 소유한다.
+ */
+const TYPE_CLASS: Record<FoxButtonType, string> = {
+  primary: styles.typePrimary,
+  secondary: styles.typeSecondary,
+  default: styles.typeDefault,
+  error: styles.typeError,
+};
+
+const SIZE_CLASS: Record<FoxButtonSize, string> = {
+  lg: styles.sizeLg,
+  md: styles.sizeMd,
+  sm: styles.sizeSm,
+  xsm: styles.sizeXsm,
+};
+
+/**
+ * @fox 공용 버튼.
+ *
+ * 상태를 갖지 않는 프레젠테이션 컴포넌트다 — `loading`·`disabled`는 호출부가 소유하고
+ * 버튼은 그에 따라 상호작용을 막기만 한다. 버튼이 스스로 로딩을 켜면 호출부의 상태와
+ * 어긋날 수 있어 그 책임을 지지 않는다.
+ *
+ * 모양은 Figma 시안(관리자페이지 node 390:439)을 토큰으로만 옮긴 것이며 규칙은
+ * `@fox/styles/_fox-button.scss`에 있다. `error` 계열만 시안 미정이라 비어 있다.
+ */
+export function FoxButton({
+  type = "default",
+  size,
+  htmlType = "button",
+  className,
+  leadingIcon,
+  trailingIcon,
+  label,
+  disabled = false,
+  hidden = false,
+  loading = false,
+  fullWidth = false,
+  onAction,
+  onHoverChange,
+  ref,
+}: FoxButtonProps) {
+  if (hidden) {
+    return null;
+  }
+
+  // 로딩 중에도 클릭을 막아야 같은 요청이 두 번 나가지 않는다.
+  const inactive = disabled || loading;
+
+  return (
+    <button
+      ref={ref}
+      type={htmlType}
+      className={cx(
+        styles.button,
+        TYPE_CLASS[type],
+        SIZE_CLASS[size],
+        fullWidth && styles.fullWidth,
+        className
+      )}
+      disabled={inactive}
+      aria-busy={loading || undefined}
+      onClick={inactive ? undefined : onAction}
+      onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
+      onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
+    >
+      {loading ? (
+        // 스피너 비주얼은 시안에 없어 자리만 잡아둔다. 아이콘과 같은 크기를 차지하므로
+        // 로딩으로 바뀔 때 버튼 폭이 흔들리지 않는다.
+        <span className={styles.spinner} aria-hidden="true" />
+      ) : (
+        leadingIcon && (
+          <span className={styles.icon} aria-hidden="true">
+            {leadingIcon}
+          </span>
+        )
+      )}
+
+      <span className={styles.label}>{label}</span>
+
+      {trailingIcon && (
+        <span className={styles.icon} aria-hidden="true">
+          {trailingIcon}
+        </span>
+      )}
+    </button>
+  );
+}
 
@fox/core/components/fox-button/index.ts (added)
+++ @fox/core/components/fox-button/index.ts
@@ -0,0 +1,6 @@
+export {
+  FoxButton,
+  type FoxButtonProps,
+  type FoxButtonType,
+  type FoxButtonSize,
+} from "./fox-button";
 
@fox/core/components/index.ts (added)
+++ @fox/core/components/index.ts
@@ -0,0 +1,1 @@
+export * from "./fox-button";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -1,4 +1,5 @@
 import type { ReactNode } from "react";
+import { FoxButton } from "../core/components/fox-button";
 
 /**
  * 컴포넌트 예제 하나. 사이드바의 "컴포넌트" 분류에 항목으로 뜬다.
@@ -17,24 +18,89 @@
   variants: { label: string; node: ReactNode }[];
 }
 
+// 예제용 아이콘 — 시안의 실제 아이콘이 아니라 자리 확인용 도형이다.
+// `currentColor`로 그려야 버튼이 계열별로 색을 입힐 수 있다(슬롯이 `color`를 내려준다).
+// 크기는 슬롯이 `form-icon-*` 토큰으로 정하므로 100%로 채우기만 한다.
+function DemoIcon() {
+  return (
+    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
+      <path d="M6 3l5 5-5 5" />
+    </svg>
+  );
+}
+
 /**
  * 컴포넌트를 만들 때마다 여기에 한 항목씩 추가한다. 사이드바·본문은 이 배열만 보고
  * 그리므로 다른 파일을 고칠 필요가 없다.
- *
- * ```tsx
- * import { Button } from "../core/components/button";
- *
- * export const COMPONENT_EXAMPLES: ComponentExample[] = [
- *   {
- *     id: "button",
- *     name: "Button",
- *     description: "기본 액션 버튼",
- *     variants: [
- *       { label: "primary", node: <Button variant="primary">확인</Button> },
- *       { label: "disabled", node: <Button disabled>확인</Button> },
- *     ],
- *   },
- * ];
- * ```
  */
-export const COMPONENT_EXAMPLES: ComponentExample[] = [];
+export const COMPONENT_EXAMPLES: ComponentExample[] = [
+  {
+    id: "fox-button",
+    name: "FoxButton",
+    description:
+      "Figma 시안(관리자페이지 node 390:439)을 토큰만으로 옮겼습니다. 너비는 내용에 따라 가변이고 높이만 고정입니다. hover·pressed는 마우스를 올리거나 눌러 확인하세요. error 계열은 시안 미정이라 비어 있습니다.",
+    variants: [
+      {
+        label: "계열 (type) — hover·pressed 확인 가능",
+        node: (
+          <>
+            <FoxButton size="md" label="default" />
+            <FoxButton size="md" type="primary" label="primary" />
+            <FoxButton size="md" type="secondary" label="secondary" />
+            <FoxButton size="md" type="error" label="error (시안 미정)" />
+          </>
+        ),
+      },
+      {
+        label: "크기 (size)",
+        node: (
+          <>
+            <FoxButton size="lg" label="lg" />
+            <FoxButton size="md" label="md" />
+            <FoxButton size="sm" label="sm" />
+            <FoxButton size="xsm" label="xsm" />
+          </>
+        ),
+      },
+      {
+        label: "아이콘",
+        node: (
+          <>
+            <FoxButton size="md" label="leading" leadingIcon={<DemoIcon />} />
+            <FoxButton size="md" label="trailing" trailingIcon={<DemoIcon />} />
+            <FoxButton size="md" label="both" leadingIcon={<DemoIcon />} trailingIcon={<DemoIcon />} />
+          </>
+        ),
+      },
+      {
+        label: "상태 — disabled / loading (둘 다 클릭이 막힌다)",
+        node: (
+          <>
+            <FoxButton size="md" label="normal" onAction={() => undefined} />
+            <FoxButton size="md" label="disabled" disabled />
+            <FoxButton size="md" label="loading" loading />
+            <FoxButton size="md" label="loading + icon" loading leadingIcon={<DemoIcon />} />
+          </>
+        ),
+      },
+      {
+        label: "fullWidth",
+        node: <FoxButton size="md" label="fullWidth" fullWidth />,
+      },
+      {
+        label: "htmlType — 폼 안에서의 역할",
+        node: (
+          <>
+            <FoxButton size="md" label="button (기본)" />
+            <FoxButton size="md" label="submit" htmlType="submit" />
+            <FoxButton size="md" label="reset" htmlType="reset" />
+          </>
+        ),
+      },
+      {
+        label: "hidden — 렌더되지 않는다(아래는 비어 있는 것이 정상)",
+        node: <FoxButton size="md" label="hidden" hidden />,
+      },
+    ],
+  },
+];
 
@fox/styles/_fox-button.scss (added)
+++ @fox/styles/_fox-button.scss
@@ -0,0 +1,194 @@
+// FoxButton 스타일 — 시안: 관리자페이지 Figma, node 390:439 (`btn`)
+//
+// **모든 디자인 값은 토큰으로만 지목한다.** 시안의 Figma 변수가 우리 토큰과 1:1로
+// 대응하므로 예외가 필요 없었고, 아래 세 가지만 토큰이 아니다(사용자 승인 후 사용):
+//
+//   1. `line-height: 1` / `letter-spacing: -0.025em`
+//      시안 텍스트 스타일 `label/*-strong`의 값이다(lineHeight 1, letterSpacing -2.5%).
+//      Figma에서 텍스트 스타일로만 존재하고 변수로 export되지 않아 토큰이 없다.
+//      네 크기가 모두 같은 값이라 스케일이 아니고 규칙 하나로 끝난다.
+//   2. `box-sizing: border-box`
+//      높이가 고정인데 secondary·default가 1px 테두리를 가져, 없으면 계열별로 바깥
+//      높이가 2px 어긋난다. 디자인 값이 아니라 레이아웃 메커니즘이다.
+//   3. `cursor` / `white-space` / `flex-shrink` 등 구조·상호작용 규칙.
+//
+// 이 파일은 CSS를 출력하므로 `fox-button.module.scss` **한 곳에서만** `@use`한다.
+// 전역 진입점(`index.scss`)에 넣으면 안 된다.
+
+@use "abstracts" as fox;
+
+.button {
+  box-sizing: border-box;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  cursor: pointer;
+
+  // 시안 label/*-strong. 크기별 font-size는 아래 size 클래스가 정한다.
+  font-family: fox.font-family(body);
+  font-weight: fox.font-weight(medium);
+  line-height: 1;
+  letter-spacing: -0.025em;
+  white-space: nowrap;
+}
+
+.button:disabled {
+  cursor: default;
+}
+
+.fullWidth {
+  inline-size: 100%;
+}
+
+// 라벨이 길어도 아이콘을 밀어내지 않게 라벨만 줄어들 수 있게 둔다.
+.label {
+  min-inline-size: 0;
+}
+
+// 아이콘 슬롯. 크기는 size 클래스가, 색은 type 클래스가 정한다 —
+// 안에 들어오는 SVG는 `currentColor`로 그려야 계열별 색이 적용된다.
+.icon,
+.spinner {
+  display: inline-flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+}
+
+.icon > * {
+  inline-size: 100%;
+  block-size: 100%;
+}
+
+// ── 크기 ────────────────────────────────────────────────────────────────────
+// 높이만 고정이고 너비는 내용에 따라 가변이다(시안도 좌우 padding만 지정).
+.sizeLg {
+  block-size: fox.form(height-lg);
+  padding-inline: fox.form(padding-lg);
+  border-radius: fox.form(radius-lg);
+  gap: fox.gap(2);
+  font-size: fox.font-size(label-lg);
+
+  .icon,
+  .spinner {
+    inline-size: fox.form(icon-lg);
+    block-size: fox.form(icon-lg);
+  }
+}
+
+.sizeMd {
+  block-size: fox.form(height-md);
+  padding-inline: fox.form(padding-md);
+  border-radius: fox.form(radius-md);
+  gap: fox.gap(2);
+  font-size: fox.font-size(label-md);
+
+  .icon,
+  .spinner {
+    inline-size: fox.form(icon-md);
+    block-size: fox.form(icon-md);
+  }
+}
+
+.sizeSm {
+  block-size: fox.form(height-sm);
+  padding-inline: fox.form(padding-sm);
+  border-radius: fox.form(radius-sm);
+  gap: fox.gap(2);
+  font-size: fox.font-size(label-sm);
+
+  .icon,
+  .spinner {
+    inline-size: fox.form(icon-sm);
+    block-size: fox.form(icon-sm);
+  }
+}
+
+// xsm만 gap이 한 단계 좁다(시안 확인).
+.sizeXsm {
+  block-size: fox.form(height-xsm);
+  padding-inline: fox.form(padding-xsm);
+  border-radius: fox.form(radius-xsm);
+  gap: fox.gap(1);
+  font-size: fox.font-size(label-xsm);
+
+  .icon,
+  .spinner {
+    inline-size: fox.form(icon-xsm);
+    block-size: fox.form(icon-xsm);
+  }
+}
+
+// ── 계열 ────────────────────────────────────────────────────────────────────
+// 아이콘 색은 라벨 색과 별개 토큰으로 지정돼 있어(시안) 각각 지목한다 —
+// 지금은 두 값이 같지만 토큰이 갈라지면 시안을 따라가야 한다.
+.typePrimary {
+  background: fox.color(button-primary-surface);
+  color: fox.color(button-primary-font);
+
+  .icon {
+    color: fox.color(icon-neutral-static-inverse);
+  }
+
+  &:hover:not(:disabled) {
+    background: fox.color(button-primary-surface-hover);
+  }
+
+  &:active:not(:disabled) {
+    background: fox.color(button-primary-surface-pressed);
+  }
+}
+
+.typeSecondary {
+  border: fox.border(1) solid fox.color(button-secondary-border);
+  background: fox.color(button-secondary-surface);
+  color: fox.color(button-secondary-font);
+
+  .icon {
+    color: fox.color(icon-theme-primary-strong);
+  }
+
+  &:hover:not(:disabled) {
+    background: fox.color(button-secondary-surface-hover);
+  }
+
+  &:active:not(:disabled) {
+    background: fox.color(button-secondary-surface-pressed);
+  }
+}
+
+.typeDefault {
+  border: fox.border(1) solid fox.color(button-default-border);
+  background: fox.color(button-default-surface);
+  color: fox.color(button-default-font);
+
+  .icon {
+    color: fox.color(icon-neutral-default);
+  }
+
+  &:hover:not(:disabled) {
+    background: fox.color(button-default-surface-hover);
+  }
+
+  &:active:not(:disabled) {
+    background: fox.color(button-default-surface-pressed);
+  }
+}
+
+// ⚠️ error 계열은 시안 미정이라 규칙을 두지 않는다. 확정되면 여기에 채운다.
+.typeError {
+}
+
+// ── 비활성 ──────────────────────────────────────────────────────────────────
+// 계열과 무관하게 같은 모양이고 **테두리가 없다**(시안에서 disabled-border 미사용).
+// `.button:disabled`가 계열 클래스보다 특이도가 높아 뒤에 두지 않아도 이긴다.
+.button:disabled {
+  border: none;
+  background: fox.color(button-disabled-surface);
+  color: fox.color(button-disabled-font);
+
+  .icon {
+    color: fox.color(icon-neutral-disabled-strong);
+  }
+}
Add a comment
List