임동욱 임동욱 08-18
feat: FoxSpinner 추가하고 버튼 로딩 자리를 채움
관리자페이지 파일의 img-loading(5384:9710)을 옮겼다. 시안은 80px 그림 한 장으로 왔지만
래스터를 그대로 쓰지 않았다 — 크기를 바꾸면 뭉개지고 색도 토큰을 못 쓴다. 원본 픽셀을
재서 같은 도형을 CSS로 다시 그렸다.

잰 값: 바깥 반지름 40 · 안쪽 28 → 굵기 12px(지름의 15%), 앞머리 원 12px이 6시 자리 고리
중심선 위. 색은 6시에서 시작해 시계방향 60°까지 알파 1.00이고 나머지 300°에 걸쳐 0까지
선형으로 준다(5°마다 샘플링해 확인).

굵기·앞머리를 퍼센트로 잡아 어떤 지름에서도 비율이 같다. `size`만 주면 된다.

FoxButton의 로딩 자리는 "시안 미정"으로 비어 있었는데 이제 이 스피너가 들어간다.
버튼 안에서는 `color: inherit`으로 되돌려 계열별 글자색을 따라간다 — 요소에 준 색이
상속보다 강해서, 기본색을 두는 한 감싸기만으로는 바뀌지 않기 때문이다.

승인된 비토큰 값: 지름 8rem, 굵기·앞머리 15%, 마스크 정지점 70%, 그라디언트 60°.
한 바퀴 도는 시간 1초는 시안이 정지 그림이라 정보가 없어 흔한 기본값을 썼다.

검증: 지름 80 · 색 #256ef4 · 앞머리 12×12가 중심에서 아래로 34px(시안과 동일) ·
그라디언트 from 180deg 0~60deg 불투명. 버튼 세 크기에서 스피너 색이 글자색과 일치하고
아이콘 슬롯 크기를 그대로 채운다.

Co-Authored-By: Claude Opus 5 
@3a30710db0e36bd908560e8726cf9e5eb88cd107
@fox/core/components/fox-button/fox-button.tsx
--- @fox/core/components/fox-button/fox-button.tsx
+++ @fox/core/components/fox-button/fox-button.tsx
@@ -2,6 +2,7 @@
 
 import type { ReactNode, Ref } from "react";
 import { cx } from "../../utils";
+import { FoxSpinner } from "../fox-spinner";
 
 export type FoxButtonType = "primary" | "secondary" | "default" | "error";
 export type FoxButtonSize = "lg" | "md" | "sm" | "xsm";
@@ -91,8 +92,11 @@
       onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
     >
       {loading ? (
-        // 스피너 비주얼은 시안 미정. 아이콘과 같은 크기라 전환 시 폭이 흔들리지 않는다.
-        <span className="fox-button__spinner" aria-hidden="true" />
+        // 아이콘과 같은 크기라 전환 시 폭이 흔들리지 않는다. 이름은 버튼이 `aria-busy`로
+        // 이미 알리므로 스피너는 잠재운다.
+        <span className="fox-button__spinner" aria-hidden="true">
+          <FoxSpinner size="100%" label="" />
+        </span>
       ) : (
         leadingIcon && (
           <span className="fox-button__icon" aria-hidden="true">
 
@fox/core/components/fox-spinner/fox-spinner.tsx (added)
+++ @fox/core/components/fox-spinner/fox-spinner.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { cx } from "../../utils";
+
+export interface FoxSpinnerProps {
+  /**
+   * 지름. CSS 길이면 무엇이든 되고, 굵기와 끝점도 함께 비율로 줄어든다.
+   * 넘기지 않으면 시안 크기(80px)다. 이미 크기가 정해진 자리에 넣을 때는 `"100%"`를 준다.
+   */
+  size?: string | number;
+  /**
+   * 스크린리더가 읽을 말. 빈 문자열을 주면 잠재운다 — 옆 글자가 이미 "불러오는 중"이라고
+   * 말하고 있을 때 쓴다.
+   */
+  label?: string;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 로딩 표시. 한 바퀴 도는 고리와 그 앞머리의 동그라미로 이뤄진다.
+ *
+ * 색은 감싼 쪽의 `color`를 따른다 — 버튼 안에서는 글자색과 같아지고, 홀로 쓰면 테마
+ * 기본색이다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxSpinner({ size, label = "불러오는 중", hidden = false, className }: FoxSpinnerProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <span
+      className={cx("fox-spinner", className)}
+      style={size === undefined ? undefined : { inlineSize: size, blockSize: size }}
+      role={label ? "status" : undefined}
+      aria-label={label || undefined}
+    >
+      <span className="fox-spinner__arc" />
+      <span className="fox-spinner__head" />
+    </span>
+  );
+}
 
@fox/core/components/fox-spinner/index.ts (added)
+++ @fox/core/components/fox-spinner/index.ts
@@ -0,0 +1,1 @@
+export { FoxSpinner, type FoxSpinnerProps } from "./fox-spinner";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -30,6 +30,7 @@
 export * from "./fox-select";
 export * from "./fox-select-option";
 export * from "./fox-select-option-item";
+export * from "./fox-spinner";
 export * from "./fox-tab";
 export * from "./fox-tab-item";
 export * from "./fox-table";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -49,6 +49,7 @@
 import { FoxTableRow } from "../core/components/fox-table-row";
 import { FoxTab } from "../core/components/fox-tab";
 import { FoxTabItem } from "../core/components/fox-tab-item";
+import { FoxSpinner } from "../core/components/fox-spinner";
 import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
@@ -2806,4 +2807,45 @@
       },
     ],
   },
+  {
+    id: "fox-spinner",
+    name: "FoxSpinner",
+    description:
+      "Figma 시안(img-loading 5384:9710, 관리자페이지 파일)을 옮겼습니다. 시안은 80px 그림 한 장으로 왔지만 래스터를 그대로 쓰지 않고 픽셀을 재서 같은 도형을 CSS로 다시 그렸습니다 — 크기를 바꿔도 뭉개지지 않고 색이 토큰을 따릅니다. 색은 기본이 테마 기본색이고, 버튼 안에서는 계열별 글자색을 따라갑니다.",
+    variants: [
+      {
+        label: "시안 크기 (80px)",
+        node: <FoxSpinner />,
+      },
+      {
+        label: "크기 (size) — 굵기와 앞머리가 비율로 함께 줄어듭니다",
+        node: (
+          <div className={styles.demoRow}>
+            <FoxSpinner size={80} />
+            <FoxSpinner size={48} />
+            <FoxSpinner size={32} />
+            <FoxSpinner size={20} />
+          </div>
+        ),
+      },
+      {
+        label: "기본은 테마 기본색 — 버튼 안에서는 글자색을 따라갑니다",
+        node: (
+          <div className={styles.demoRow}>
+            <FoxSpinner size={40} />
+          </div>
+        ),
+      },
+      {
+        label: "버튼 로딩 — 앞 아이콘 자리에 들어갑니다",
+        node: (
+          <div className={styles.demoRow}>
+            <FoxButton size="lg" type="primary" label="저장" loading />
+            <FoxButton size="md" type="secondary" label="저장" loading />
+            <FoxButton size="sm" label="저장" loading />
+          </div>
+        ),
+      },
+    ],
+  },
 ];
@fox/styles/_fox-button.scss
--- @fox/styles/_fox-button.scss
+++ @fox/styles/_fox-button.scss
@@ -7,6 +7,7 @@
 //     <span class="fox-button__icon">…svg…</span>   <!-- 선택 -->
 //   </button>
 // 비활성은 네이티브 `disabled`, 로딩은 앞 아이콘 자리에 `fox-button__spinner` + `disabled`.
+// 그 안에 `fox-spinner`가 들어가며, 색은 `currentColor`라 계열별 글자색을 따라간다.
 // 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용된다.
 //
 // 토큰이 아닌 값 3건(사용자 승인):
 
@fox/styles/_fox-spinner.scss (added)
+++ @fox/styles/_fox-spinner.scss
@@ -0,0 +1,82 @@
+// FoxSpinner — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) img-loading (5384:9710)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <span class="fox-spinner" role="status" aria-label="불러오는 중">
+//     <span class="fox-spinner__arc"></span>
+//     <span class="fox-spinner__head"></span>
+//   </span>
+//
+// 시안은 80px 그림 한 장으로 왔지만 래스터를 그대로 쓰지 않는다 — 크기를 바꾸면 뭉개지고
+// 색도 토큰을 못 쓴다. 픽셀을 재서 같은 도형을 CSS로 다시 그렸다.
+//   - 고리: 바깥 반지름 40 · 안쪽 28 → 굵기 12px (지름의 15%)
+//   - 색: 6시에서 시작해 시계방향으로 60°까지 진하고, 나머지 300°에 걸쳐 투명해진다
+//   - 앞머리: 지름 12px 원이 6시 자리 고리 중심선 위에 얹힌다 (고리 끝을 둥글게 맺는다)
+// 원본 그림의 알파를 5°마다 재서 확인한 값이다(185~240° = 1.00, 이후 180°까지 선형 감소).
+//
+// 크기는 지름 하나로 정해진다. 굵기·앞머리를 퍼센트로 잡아 어떤 지름에서도 비율이 같다.
+//
+// 색은 기본이 테마 기본색이다(시안). 버튼처럼 글자색을 따라야 하는 자리는 감싼 쪽에서
+// `color: inherit`으로 되돌린다 — 요소에 색을 직접 주는 편이 상속보다 강해서, 기본값을
+// 두는 한 감싸기만으로는 바뀌지 않는다.
+//
+// 토큰이 아닌 값:
+//   - 지름 8rem, 굵기·앞머리 15%, 마스크 정지점 70%, 그라디언트 60°: 시안에서 잰 값이나
+//     Figma가 변수로 export하지 않는다(사용자 승인).
+//   - 한 바퀴 도는 시간 1초: 시안이 정지 그림이라 정보가 없다. 흔한 기본값을 쓴다.
+//   - transform / mask / animation: 구조·움직임 규칙.
+
+@use "abstracts" as fox;
+
+@keyframes fox-spinner-turn {
+  to {
+    transform: rotate(1turn);
+  }
+}
+
+.fox-spinner {
+  position: relative;
+  display: inline-block;
+  flex-shrink: 0;
+  // 시안 값이나 변수로 export되지 않는다 — 파일 상단 참고.
+  inline-size: 8rem;
+  block-size: 8rem;
+  color: fox.color(element-theme-primary);
+  animation: fox-spinner-turn 1s linear infinite;
+
+  // 버튼 안에서는 계열별 글자색을 따라간다.
+  .fox-button__spinner > & {
+    color: inherit;
+  }
+
+  // 고리. 원뿔 그라디언트를 그린 뒤 가운데를 도려내 굵기를 만든다.
+  &__arc {
+    position: absolute;
+    inset: 0;
+    border-radius: fox.radius(max);
+    background: conic-gradient(
+      from 180deg,
+      currentColor 0deg,
+      currentColor 60deg,
+      transparent 360deg
+    );
+    // 안쪽 70%를 지운다 — 반지름의 30%가 곧 지름의 15%다.
+    mask: radial-gradient(farthest-side, transparent 70%, #000 70%);
+  }
+
+  // 고리 앞머리. 끝을 둥글게 맺어 준다.
+  &__head {
+    position: absolute;
+    inset-block-end: 0;
+    inset-inline-start: 50%;
+    inline-size: 15%;
+    block-size: 15%;
+    transform: translateX(-50%);
+    border-radius: fox.radius(max);
+    background: currentColor;
+  }
+
+  // 움직임을 줄이려는 사용자에게는 천천히 돈다 — 멈추면 무엇을 기다리는지 알 수 없다.
+  @media (prefers-reduced-motion: reduce) {
+    animation-duration: 3s;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -43,3 +43,4 @@
 @use "fox-table-cell";
 @use "fox-tab";
 @use "fox-tab-item";
+@use "fox-spinner";
Add a comment
List