임동욱 임동욱 08-13
feat: FoxEmail·FoxAddress 추가
Figma form-email(136:3092)과 form-address(5346:5823)를 옮긴다. 두 시안 모두
상자가 input·select 인스턴스이고 버튼도 btn 인스턴스라 FoxInput·FoxSelect·
FoxButton을 그대로 조합했다 — 두 SCSS는 배치와 `@` 구분자만 갖는다.

FoxEmail
  값은 아이디@도메인이 합쳐진 문자열 하나다. 도메인을 목록에서 고르면 옆 칸이
  그 값으로 덮이고 읽기 전용이 되며, "직접입력"을 고르면 잠금만 풀린다.
  확인 버튼은 onVerify를 넘겼을 때만 렌더된다.
  칸은 시안의 240px을 최소폭으로 두고 남는 폭을 나눠 갖는다 — 좁아지면 줄이
  넘어간다(사용자 승인). 토큰 스케일에 240이 없어 이 한 줄만 리터럴이다.

FoxAddress
  우편번호와 주소는 검색 결과로만 채워지는 읽기 전용이고 상세주소만 입력한다.
  어떤 우편번호 서비스를 쓰는지 @fox는 모른다 — 호출부가 onSearch에서
  FoxAddressSearchResult(zipCode·address·선택 detail)로 바꿔 돌려주고, 취소는
  null이다. detail을 안 넘기면 사용자가 적어 둔 상세주소를 건드리지 않는다.

Co-Authored-By: Claude Opus 5 
@84b98ea5d32d0f7a33839cdb7571c2ceae1284fa
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -149,6 +149,12 @@
   비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에
   `data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`).
 - 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다.
+- 이메일은 값이 `아이디@도메인`이 합쳐진 문자열 하나입니다. 도메인을 드롭다운에서 고르면
+  옆 칸에 그 값이 채워지고 읽기 전용이 되며, `직접입력`을 고르면 풀립니다. 확인 버튼은
+  `onVerify`를 넘겼을 때만 생깁니다.
+- 주소는 우편번호와 주소가 검색 결과로만 채워지는 읽기 전용이고 상세주소만 입력합니다.
+  어떤 우편번호 서비스를 쓸지는 `@fox`가 모릅니다 — 호출부가 `onSearch`에서 결과를
+  `FoxAddressSearchResult`(`zipCode`·`address`·선택 `detail`)로 돌려주고, 취소는 `null`입니다.
 - 휴대폰 인증은 보내기·확인을 호출부가 `Promise`로 넘깁니다 — resolve면 성공, reject면
   실패이고 결과는 이벤트로 올라옵니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고,
   인증에 성공하면 전부 잠깁니다. 남은 시간은 `remainingSeconds`를 주면 그 값을, 없으면
 
@fox/core/components/fox-address/fox-address.tsx (added)
+++ @fox/core/components/fox-address/fox-address.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import { useId, useState, type ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxButton } from "../fox-button";
+import { FoxInput } from "../fox-input";
+
+/**
+ * 주소 검색 결과. 호출부가 어떤 우편번호 서비스를 쓰든 이 모양으로 바꿔서 돌려준다 —
+ * 그래야 `@fox`가 특정 API에 묶이지 않는다.
+ */
+export interface FoxAddressSearchResult {
+  /** 우편번호. 첫 칸에 그대로 들어간다. */
+  zipCode: string;
+  /** 도로명 또는 지번 주소. 둘째 칸에 그대로 들어간다. 어느 쪽을 쓸지는 호출부가 정한다. */
+  address: string;
+  /**
+   * 상세주소 칸에 미리 채울 값(건물명 같은 참고항목).
+   * 넘기지 않으면 사용자가 적어 둔 상세주소를 건드리지 않는다.
+   */
+  detail?: string;
+}
+
+/** 주소 한 벌. 화면의 세 칸을 그대로 담는다. */
+export interface FoxAddressValue {
+  zipCode: string;
+  address: string;
+  detail: string;
+}
+
+export const EMPTY_ADDRESS: FoxAddressValue = { zipCode: "", address: "", detail: "" };
+
+export interface FoxAddressProps {
+  value?: FoxAddressValue;
+  defaultValue?: FoxAddressValue;
+  onChange?: (value: FoxAddressValue) => void;
+
+  /**
+   * 주소 검색. 호출부가 우편번호 서비스를 띄우고 결과를 `FoxAddressSearchResult`로
+   * 돌려준다. 사용자가 고르지 않고 닫으면 `null`을 돌려주면 되고, 그때는 값이 그대로
+   * 남는다. throw·reject하면 `onSearchFail`을 부른다.
+   */
+  onSearch: () => Promise<FoxAddressSearchResult | null>;
+  onSearchSuccess?: (result: FoxAddressSearchResult) => void;
+  onSearchFail?: (error: unknown) => void;
+  searchLabel?: string;
+
+  label?: string;
+  /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */
+  message?: string;
+  messageIcon?: ReactNode;
+  zipCodePlaceholder?: string;
+  addressPlaceholder?: string;
+  detailPlaceholder?: string;
+
+  invalid?: boolean;
+  disabled?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 주소 입력. 우편번호와 주소는 검색 결과로만 채워지고, 상세주소만 손으로 적는다.
+ *
+ * 어떤 우편번호 서비스를 쓸지는 `@fox`가 알 필요가 없다 — 호출부가 `onSearch`에서 그
+ * 서비스를 띄우고 결과를 `FoxAddressSearchResult` 모양으로 바꿔서 돌려주면 된다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxAddress({
+  value,
+  defaultValue,
+  onChange,
+  onSearch,
+  onSearchSuccess,
+  onSearchFail,
+  searchLabel = "주소 검색",
+  label,
+  message,
+  messageIcon,
+  zipCodePlaceholder = "우편번호",
+  addressPlaceholder = "주소",
+  detailPlaceholder = "상세주소",
+  invalid = false,
+  disabled = false,
+  hidden = false,
+  className,
+}: FoxAddressProps) {
+  const autoId = useId();
+  const [inner, setInner] = useState<FoxAddressValue>(defaultValue ?? EMPTY_ADDRESS);
+  const [searching, setSearching] = useState(false);
+
+  if (hidden) {
+    return null;
+  }
+
+  const current = value ?? inner;
+  const messageId = `${autoId}-message`;
+
+  const commit = (next: FoxAddressValue) => {
+    if (value === undefined) {
+      setInner(next);
+    }
+    onChange?.(next);
+  };
+
+  const handleSearch = async () => {
+    if (searching) {
+      return;
+    }
+    setSearching(true);
+    try {
+      const result = await onSearch();
+      // 취소하면 `null`이 온다 — 그때는 값이 그대로 남는다.
+      if (result) {
+        commit({
+          zipCode: result.zipCode,
+          address: result.address,
+          detail: result.detail ?? current.detail,
+        });
+        onSearchSuccess?.(result);
+      }
+    } catch (error) {
+      onSearchFail?.(error);
+    } finally {
+      setSearching(false);
+    }
+  };
+
+  return (
+    <div className={cx("fox-address", className)}>
+      {label && (
+        <span className="fox-address__label" id={`${autoId}-label`}>
+          {label}
+        </span>
+      )}
+
+      <div className="fox-address__row">
+        <div className="fox-address__zip">
+          {/* 검색 결과로만 채워진다 — 손으로 고칠 수 없다. */}
+          <FoxInput
+            size="md"
+            aria-label={zipCodePlaceholder}
+            aria-describedby={message ? messageId : undefined}
+            value={current.zipCode}
+            placeholder={zipCodePlaceholder}
+            readOnly
+            invalid={invalid}
+            disabled={disabled}
+          />
+        </div>
+
+        <FoxButton
+          size="md"
+          type="secondary"
+          label={searchLabel}
+          loading={searching}
+          disabled={disabled}
+          onAction={handleSearch}
+        />
+      </div>
+
+      <FoxInput
+        size="md"
+        aria-label={addressPlaceholder}
+        value={current.address}
+        placeholder={addressPlaceholder}
+        readOnly
+        invalid={invalid}
+        disabled={disabled}
+      />
+
+      <FoxInput
+        size="md"
+        aria-label={detailPlaceholder}
+        value={current.detail}
+        onChange={(next) => commit({ ...current, detail: next })}
+        placeholder={detailPlaceholder}
+        invalid={invalid}
+        disabled={disabled}
+      />
+
+      {message && (
+        <p className="fox-address__message" id={messageId}>
+          {messageIcon && (
+            <span className="fox-address__message-icon" aria-hidden="true">
+              {messageIcon}
+            </span>
+          )}
+          {message}
+        </p>
+      )}
+    </div>
+  );
+}
 
@fox/core/components/fox-address/index.ts (added)
+++ @fox/core/components/fox-address/index.ts
@@ -0,0 +1,7 @@
+export {
+  FoxAddress,
+  EMPTY_ADDRESS,
+  type FoxAddressProps,
+  type FoxAddressValue,
+  type FoxAddressSearchResult,
+} from "./fox-address";
 
@fox/core/components/fox-email/fox-email.tsx (added)
+++ @fox/core/components/fox-email/fox-email.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { useId, useState, type ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxButton } from "../fox-button";
+import { FoxInput } from "../fox-input";
+import { FoxSelect, type FoxSelectItem } from "../fox-select";
+
+/** 드롭다운의 "직접입력" 항목이 쓰는 값. 실제 도메인과 겹치지 않게 잡았다. */
+const CUSTOM_DOMAIN = "__fox-email-custom__";
+
+export interface FoxEmailProps {
+  /** `아이디@도메인` 전체 값. 둘 다 비어 있으면 빈 문자열이다. */
+  value?: string;
+  defaultValue?: string;
+  onChange?: (value: string) => void;
+  /** 도메인 드롭다운 목록. "직접입력" 항목은 컴포넌트가 맨 아래에 더한다. */
+  domainOptions?: string[];
+  customDomainLabel?: string;
+
+  /**
+   * 주면 확인 버튼이 생기고, 넘기지 않으면 버튼 자체가 렌더되지 않는다.
+   * resolve하면 성공, throw·reject하면 실패로 본다.
+   */
+  onVerify?: (email: string) => Promise<unknown>;
+  onVerifySuccess?: () => void;
+  onVerifyFail?: (error: unknown) => void;
+  verifyLabel?: string;
+
+  label?: string;
+  /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */
+  message?: string;
+  messageIcon?: ReactNode;
+  localPlaceholder?: string;
+  domainPlaceholder?: string;
+  customPlaceholder?: string;
+
+  invalid?: boolean;
+  disabled?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+type Split = { local: string; domain: string; custom: boolean };
+
+/**
+ * `아이디@도메인`을 칸으로 나눈다. `@`는 마지막 것을 기준으로 자른다.
+ *
+ * 나눈 도메인이 목록에 없으면 직접 적은 것으로 보고 드롭다운을 "직접입력"에 맞춘다.
+ */
+function splitEmail(value: string, domainOptions: string[]): Split {
+  const at = (value ?? "").lastIndexOf("@");
+  if (at < 0) {
+    return { local: value ?? "", domain: "", custom: false };
+  }
+  const domain = value.slice(at + 1);
+  return {
+    local: value.slice(0, at),
+    domain,
+    custom: domain !== "" && !domainOptions.includes(domain),
+  };
+}
+
+function joinEmail(local: string, domain: string): string {
+  return local || domain ? `${local}@${domain}` : "";
+}
+
+/**
+ * @fox 이메일 입력. 아이디와 도메인을 나눠 받되 값은 합쳐진 문자열 하나다.
+ *
+ * 도메인은 드롭다운에서 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 된다. "직접입력"을
+ * 고르면 그 칸이 풀려 손으로 적을 수 있다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxEmail({
+  value,
+  defaultValue,
+  onChange,
+  domainOptions = [],
+  customDomainLabel = "직접입력",
+  onVerify,
+  onVerifySuccess,
+  onVerifyFail,
+  verifyLabel = "확인",
+  label,
+  message,
+  messageIcon,
+  localPlaceholder = "아이디",
+  domainPlaceholder = "도메인 선택",
+  customPlaceholder = "직접입력",
+  invalid = false,
+  disabled = false,
+  hidden = false,
+  className,
+}: FoxEmailProps) {
+  const autoId = useId();
+  const [parts, setParts] = useState<Split>(() =>
+    splitEmail(value ?? defaultValue ?? "", domainOptions)
+  );
+  const [seenValue, setSeenValue] = useState(value);
+  const [verifying, setVerifying] = useState(false);
+
+  // 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다.
+  if (value !== undefined && value !== seenValue) {
+    setSeenValue(value);
+    if (value !== joinEmail(parts.local, parts.domain)) {
+      setParts(splitEmail(value, domainOptions));
+    }
+  }
+
+  if (hidden) {
+    return null;
+  }
+
+  const messageId = `${autoId}-message`;
+  const email = joinEmail(parts.local, parts.domain);
+
+  const commit = (next: Split) => {
+    setParts(next);
+    const joined = joinEmail(next.local, next.domain);
+    setSeenValue(joined);
+    onChange?.(joined);
+  };
+
+  const domainItems: FoxSelectItem[] = [
+    ...domainOptions.map((option) => ({ value: option, label: option })),
+    { value: CUSTOM_DOMAIN, label: customDomainLabel },
+  ];
+
+  // 목록에서 고르면 옆 칸을 그 값으로 덮고 잠근다. "직접입력"이면 잠금만 푼다.
+  const handleDomainPick = (picked: string) => {
+    if (picked === CUSTOM_DOMAIN) {
+      commit({ ...parts, custom: true });
+      return;
+    }
+    commit({ ...parts, domain: picked, custom: false });
+  };
+
+  const handleVerify = async () => {
+    if (!onVerify || verifying) {
+      return;
+    }
+    setVerifying(true);
+    try {
+      await onVerify(email);
+      onVerifySuccess?.();
+    } catch (error) {
+      onVerifyFail?.(error);
+    } finally {
+      setVerifying(false);
+    }
+  };
+
+  return (
+    <div className={cx("fox-email", className)}>
+      {label && (
+        <span className="fox-email__label" id={`${autoId}-label`}>
+          {label}
+        </span>
+      )}
+
+      <div className="fox-email__row">
+        <div className="fox-email__part">
+          <FoxInput
+            size="md"
+            aria-label="이메일 아이디"
+            aria-describedby={message ? messageId : undefined}
+            value={parts.local}
+            onChange={(next) => commit({ ...parts, local: next })}
+            placeholder={localPlaceholder}
+            invalid={invalid}
+            disabled={disabled}
+          />
+        </div>
+
+        <span className="fox-email__at" aria-hidden="true">
+          @
+        </span>
+
+        <div className="fox-email__part">
+          <FoxSelect
+            size="md"
+            options={domainItems}
+            value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
+            onValueChange={handleDomainPick}
+            placeholder={domainPlaceholder}
+            error={invalid}
+            disabled={disabled}
+          />
+        </div>
+
+        {/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */}
+        <div className="fox-email__part">
+          <FoxInput
+            size="md"
+            aria-label="이메일 도메인"
+            value={parts.domain}
+            onChange={(next) => commit({ ...parts, domain: next })}
+            placeholder={customPlaceholder}
+            readOnly={!parts.custom}
+            invalid={invalid}
+            disabled={disabled}
+          />
+        </div>
+
+        {onVerify && (
+          <FoxButton
+            size="md"
+            type="primary"
+            label={verifyLabel}
+            loading={verifying}
+            disabled={disabled}
+            onAction={handleVerify}
+          />
+        )}
+      </div>
+
+      {message && (
+        <p className="fox-email__message" id={messageId}>
+          {messageIcon && (
+            <span className="fox-email__message-icon" aria-hidden="true">
+              {messageIcon}
+            </span>
+          )}
+          {message}
+        </p>
+      )}
+    </div>
+  );
+}
 
@fox/core/components/fox-email/index.ts (added)
+++ @fox/core/components/fox-email/index.ts
@@ -0,0 +1,1 @@
+export { FoxEmail, type FoxEmailProps } from "./fox-email";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -1,7 +1,9 @@
+export * from "./fox-address";
 export * from "./fox-button";
 export * from "./fox-button-group";
 export * from "./fox-button-panel";
 export * from "./fox-certified";
+export * from "./fox-email";
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -7,7 +7,9 @@
 import { FoxButtonPanel } from "../core/components/fox-button-panel";
 import { FoxIconButton } from "../core/components/fox-icon-button";
 import { FoxLinkButton } from "../core/components/fox-link-button";
+import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
 import { FoxCertified } from "../core/components/fox-certified";
+import { FoxEmail } from "../core/components/fox-email";
 import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
 import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
 import { FoxTextButton } from "../core/components/fox-text-button";
@@ -66,8 +68,8 @@
 }
 
 // 입력 예제는 폭이 있어야 시안(360px)처럼 보인다.
-function InputStage({ children }: { children: ReactNode }) {
-  return <div className={styles.demoField}>{children}</div>;
+function InputStage({ children, wide = false }: { children: ReactNode; wide?: boolean }) {
+  return <div className={wide ? styles.demoFieldWide : styles.demoField}>{children}</div>;
 }
 
 // 지우기 버튼과 자동 상태 판정을 실제로 눌러 보려면 예제가 값을 들고 있어야 한다.
@@ -157,6 +159,51 @@
           message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다"
         />
       </InputStage>
+    </>
+  );
+}
+
+const EMAIL_DOMAINS = ["naver.com", "gmail.com", "daum.net", "kakao.com"];
+
+// 값이 합쳐진 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다.
+function EmailLiveDemo() {
+  const [email, setEmail] = useState("hong@naver.com");
+  return (
+    <InputStage wide>
+      <FoxEmail
+        label={`value="${email}"`}
+        domainOptions={EMAIL_DOMAINS}
+        value={email}
+        onChange={setEmail}
+        message="도메인을 고르면 옆 칸이 채워지고 잠깁니다. 직접입력을 고르면 풀립니다."
+      />
+    </InputStage>
+  );
+}
+
+// 어떤 우편번호 서비스를 쓰든 이 모양으로 돌려주면 된다 — 여기서는 지연만 흉내낸다.
+const FAKE_SEARCH_RESULT: FoxAddressSearchResult = {
+  zipCode: "06236",
+  address: "서울 강남구 테헤란로 152",
+  detail: "강남파이낸스센터",
+};
+
+function AddressLiveDemo({ cancel = false }) {
+  const [value, setValue] = useState<FoxAddressValue>({ zipCode: "", address: "", detail: "" });
+  return (
+    <>
+      <InputStage>
+        <FoxAddress
+          label={cancel ? "검색을 취소하면 값이 그대로 남습니다" : "주소 검색을 눌러보세요"}
+          value={value}
+          onChange={setValue}
+          onSearch={async () => {
+            await new Promise((resolve) => setTimeout(resolve, 400));
+            return cancel ? null : FAKE_SEARCH_RESULT;
+          }}
+        />
+      </InputStage>
+      <pre className={styles.code}>{JSON.stringify(value, null, 2)}</pre>
     </>
   );
 }
@@ -1125,4 +1172,100 @@
       },
     ],
   },
+  {
+    id: "fox-email",
+    name: "FoxEmail",
+    description:
+      "Figma 시안(form-email 136:3092)을 옮겼습니다. 세 칸이 모두 input·select 인스턴스라 FoxInput·FoxSelect를 그대로 조합했습니다. 값은 아이디@도메인이 합쳐진 문자열 하나입니다. 도메인을 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 되며, 직접입력을 고르면 풀립니다. 칸은 최소 240px을 지키며 남는 폭을 나눠 갖고, 좁아지면 줄이 넘어갑니다.",
+    variants: [
+      {
+        label: "직접 조작 — 도메인을 바꿔보세요",
+        node: <EmailLiveDemo />,
+      },
+      {
+        label: "확인 버튼 — onVerify를 넘겼을 때만 생깁니다",
+        node: (
+          <>
+            <InputStage wide>
+              <FoxEmail
+                label="onVerify 있음"
+                domainOptions={EMAIL_DOMAINS}
+                defaultValue="hong@naver.com"
+                onVerify={async () => new Promise((resolve) => setTimeout(resolve, 400))}
+              />
+            </InputStage>
+            <InputStage wide>
+              <FoxEmail label="onVerify 없음" domainOptions={EMAIL_DOMAINS} />
+            </InputStage>
+          </>
+        ),
+      },
+      {
+        label: "좁은 곳에서는 줄이 넘어갑니다",
+        node: (
+          <InputStage>
+            <FoxEmail domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" />
+          </InputStage>
+        ),
+      },
+      {
+        label: "오류 · 비활성",
+        node: (
+          <>
+            <InputStage wide>
+              <FoxEmail
+                label="invalid"
+                domainOptions={EMAIL_DOMAINS}
+                defaultValue="hong@naver.com"
+                message="이미 쓰고 있는 주소입니다"
+                invalid
+              />
+            </InputStage>
+            <InputStage wide>
+              <FoxEmail label="disabled" domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" disabled />
+            </InputStage>
+          </>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-address",
+    name: "FoxAddress",
+    description:
+      "Figma 시안(form-address 5346:5823)을 옮겼습니다. 세 상자가 모두 input 인스턴스라 FoxInput을 그대로 조합했습니다. 우편번호와 주소는 검색 결과로만 채워지는 읽기 전용이고 상세주소만 손으로 적습니다. 어떤 우편번호 서비스를 쓸지는 호출부가 정합니다 — onSearch에서 결과를 FoxAddressSearchResult 모양으로 돌려주면 되고, 취소는 null입니다.",
+    variants: [
+      {
+        label: "직접 조작 — 검색하면 두 칸이 채워집니다",
+        node: <AddressLiveDemo />,
+      },
+      {
+        label: "검색을 취소하면(null) 값이 그대로 남습니다",
+        node: <AddressLiveDemo cancel />,
+      },
+      {
+        label: "오류 · 비활성",
+        node: (
+          <>
+            <InputStage>
+              <FoxAddress
+                label="invalid"
+                message="주소를 입력해 주세요"
+                invalid
+                onSearch={async () => null}
+              />
+            </InputStage>
+            <InputStage>
+              <FoxAddress
+                label="disabled"
+                defaultValue={{ zipCode: "06236", address: "서울 강남구 테헤란로 152", detail: "10층" }}
+                disabled
+                onSearch={async () => null}
+              />
+            </InputStage>
+          </>
+        ),
+      },
+    ],
+  },
 ];
@fox/dev-test/dev-test.module.scss
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
@@ -471,3 +471,9 @@
   inline-size: 36rem;
   max-inline-size: 100%;
 }
+
+// 이메일은 세 칸이라 360px에서는 계속 줄이 넘어간다. 넓게 볼 무대를 따로 둔다.
+.demoFieldWide {
+  inline-size: 80rem;
+  max-inline-size: 100%;
+}
 
@fox/styles/_fox-address.scss (added)
+++ @fox/styles/_fox-address.scss
@@ -0,0 +1,82 @@
+// FoxAddress — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                     Figma form-address (5346:5823 · 섹션 5383:8863)
+//
+// [우편번호 + 주소 검색] / [주소] / [상세주소]. 시안에서 세 상자가 모두 input 인스턴스이고
+// 버튼도 btn 인스턴스라, 이 파일은 줄 배치만 갖는다 — 상자 모양과 상태는 FoxInput이 가진
+// 것을 쓴다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-address">
+//     <div class="fox-address__row">
+//       <div class="fox-address__zip">…fox-input (읽기 전용)…</div>
+//       <button class="fox-button fox-button--secondary fox-button--md">주소 검색</button>
+//     </div>
+//     …fox-input (주소, 읽기 전용)…
+//     …fox-input (상세주소)…
+//   </div>
+//
+// 우편번호와 주소는 검색 결과로만 채워진다 — 손으로 고칠 수 없다. 상세주소만 입력한다.
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+
+@use "abstracts" as fox;
+
+.fox-address {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+  inline-size: 100%;
+
+  &__label {
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-md);
+    font-weight: fox.font-weight(medium);
+    line-height: 1;
+    letter-spacing: -0.025em;
+  }
+
+  // 우편번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다.
+  &__row {
+    display: flex;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    inline-size: 100%;
+  }
+
+  &__zip {
+    flex: 1 0 0;
+    min-inline-size: 0;
+  }
+
+  &__message {
+    display: flex;
+    gap: fox.number(3);
+    align-items: flex-start;
+    margin: 0;
+    color: fox.color(font-neutral-subtle);
+    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;
+  }
+
+  &__message-icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: flex-start;
+    padding-block-start: fox.padding(1);
+
+    > * {
+      inline-size: fox.icon(3);
+      block-size: fox.icon(3);
+    }
+  }
+
+  &:has([aria-invalid="true"]) .fox-address__message {
+    color: fox.color(font-system-danger);
+  }
+}
 
@fox/styles/_fox-email.scss (added)
+++ @fox/styles/_fox-email.scss
@@ -0,0 +1,95 @@
+// FoxEmail — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                   Figma form-email (136:3092 · 섹션 392:5820)
+//
+// [아이디] @ [도메인 드롭다운] [도메인 직접입력] + 선택적 확인 버튼.
+// 시안에서 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄
+// 배치와 `@` 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-email">
+//     <div class="fox-email__row">
+//       <div class="fox-email__part">…fox-input…</div>
+//       <span class="fox-email__at">@</span>
+//       <div class="fox-email__part">…fox-select…</div>
+//       <div class="fox-email__part">…fox-input…</div>
+//       <button class="fox-button fox-button--primary fox-button--md">확인</button>
+//     </div>
+//   </div>
+//
+// 토큰이 아닌 값(사용자 승인):
+//   - 칸의 `min-inline-size: 24rem`(240px): 시안이 세 칸을 240px로 고정했는데 토큰
+//     스케일에 240이 없다. 고정 대신 최소폭으로 두어 넓은 곳에서는 나눠 갖고 좁아지면
+//     줄이 넘어가게 했다.
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+
+@use "abstracts" as fox;
+
+.fox-email {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+  inline-size: 100%;
+
+  &__label {
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-md);
+    font-weight: fox.font-weight(medium);
+    line-height: 1;
+    letter-spacing: -0.025em;
+  }
+
+  // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다.
+  &__row {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: fox.gap(2);
+    inline-size: 100%;
+  }
+
+  &__part {
+    flex: 1 1 0;
+    min-inline-size: 24rem;
+  }
+
+  &__at {
+    flex-shrink: 0;
+    color: fox.color(font-neutral-default);
+    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;
+  }
+
+  &__message {
+    display: flex;
+    gap: fox.number(3);
+    align-items: flex-start;
+    margin: 0;
+    color: fox.color(font-neutral-subtle);
+    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;
+  }
+
+  &__message-icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: flex-start;
+    padding-block-start: fox.padding(1);
+
+    > * {
+      inline-size: fox.icon(3);
+      block-size: fox.icon(3);
+    }
+  }
+
+  &:has([aria-invalid="true"]) .fox-email__message {
+    color: fox.color(font-system-danger);
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -7,7 +7,9 @@
 @use "fox-icon-button";
 @use "fox-button-group";
 @use "fox-button-panel";
+@use "fox-address";
 @use "fox-certified";
+@use "fox-email";
 @use "fox-input";
 @use "fox-phone-number";
 @use "fox-select";
Add a comment
List