ldu0009 08-19 659034f feat: @fox 입력 검증기(Angular Validators 계약) + 중복 확인 실패 사유 표시 UNIX

@fox — 포터블 디자인 시스템#

프로젝트에 종속되지 않는 디자인 시스템. 이 폴더를 통째로 복사하고 설정 몇 줄을 추가하면
다른 프로젝트에서 그대로 동작합니다.

@fox/
  styles/                SCSS — 여기가 디자인의 전부입니다
    index.scss           토큰 진입점 (앱이 한 번 @use)
    components.scss      공용 컴포넌트 스타일 묶음 진입점
    _fox-*.scss          컴포넌트별 스타일 (개별 @use 가능)
    abstracts.scss       저작 진입점 — 함수·믹스인 (CSS 출력 0)
    tokens/              토큰 map = 단일 진실 공급원 (자동 생성)
    _root.scss           map → --fox-* 커스텀 프로퍼티
    _functions.scss      fox.color() 등 토큰 접근 + 이름 검증
    _mixins.scss         fox.pc / fox.mobile
  core/
    components/          React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고)
    validation/          입력 검증기 (프레임워크 무관 — 아래 "입력 검증" 참고)
    utils/               cx() 등 의존성 없는 유틸
  dev-test/              개발 전용 테스트·감사 화면
  tools/build-tokens.py  Figma JSON → SCSS 변환기

이식하기#

  1. @fox/ 폴더를 대상 프로젝트에 복사합니다.

  2. sass를 설치합니다.

    npm install --save-dev sass
  3. SCSS 로드 경로를 설정합니다. Next.js면 next.config.ts:

    sassOptions: { loadPaths: [path.join(process.cwd())] }

    다른 번들러면 그 도구의 Sass 옵션(includePaths / loadPaths)에 프로젝트 루트를
    넣으면 됩니다. 이게 있어야 @use "@fox/styles/..." 같은 절대 표기가 해석되고, 없으면
    컴포넌트마다 ../../../ 상대 경로를 써야 해서 폴더 복사가 불가능해집니다.

  4. 글로벌 스타일에서 진입점을 불러옵니다.

    @use "@fox/styles";              // 토큰
    @use "@fox/styles/components";   // 공용 컴포넌트 스타일 (전부)

    골라 쓰려면 묶음 대신 개별 파티셜을 가져옵니다 — 안 쓰는 CSS가 번들에 실리지 않습니다.
    둘을 같이 써도 Sass가 모듈을 한 번만 로드해 중복되지 않습니다.

    @use "@fox/styles/fox-button";
  5. (TypeScript 프로젝트에서 React 래퍼도 쓸 경우) 경로 별칭을 추가합니다.

    { "compilerOptions": { "paths": { "@fox/*": ["./@fox/*"] } } }
  6. 폰트를 공급합니다 — 아래 "호스트 앱과의 계약" 참고. 빠뜨리면 컴포넌트가 OS 기본
    서체로 그려져 글자 폭이 시안과 어긋납니다.

입력 검증#

@fox/core/validation은 Angular의 Validators 계약을 그대로 옮긴 것입니다. 검증기는 값을 받아
통과면 null, 아니면 오류 객체를 돌려주는 순수 함수
이고, 오류 객체의 키가 오류의 이름입니다.

import { foxValidators, foxPasswordValidator, foxValidationMessage } from "@fox/core/validation";

foxValidators.minLength(10)("abc");
// → { minlength: { requiredLength: 10, actualLength: 3 } }

문구는 검증기가 정하지 않습니다(Angular와 같은 이유입니다) — 같은 규칙도 자리에 따라 다른 말로
안내해야 하기 때문입니다. foxValidationMessage(errors, overrides)가 오류를 문구 하나로 옮기고,
overrides로 자리마다 덮어씁니다.

FoxInput은 이것을 validators·validationMessages·validateOn·onValidationChange로 받습니다.
type="password"처럼 규칙이 여러 개인 칸은 foxPasswordValidator가 한 벌로 묶어 줍니다.

<FoxInput
  type="password"
  label="비밀번호"
  message="영문·숫자·특수문자 중 2종류 이상, 10자리 이상"   // 평소엔 헬퍼
  validators={[foxPasswordValidator({ minLength: 10, kinds: 2, required: true })]}
/>
  • message는 평소 헬퍼이고, 검증이 걸리면 그 자리에 사유가 대신 들어갑니다. 호출부가
    invalid를 직접 켠 경우에는 message가 이깁니다 — 서버가 돌려준 사유(중복·권한 등)는 화면
    규칙이 알 수 없는 것이라 덮이면 안 됩니다.
  • 오류는 한 번 포커스를 벗어난 뒤부터 보입니다(validateOn="blur", 기본값). 한 글자 쳤을 때
    "10자 이상"이 뜨면 안내가 아니라 방해가 되기 때문입니다. 벗어난 뒤로는 입력할 때마다 갱신됩니다
    (Angular의 touched와 같은 판단입니다 — 다만 Angular updateOn의 기본값은 change입니다).
  • 화면 검증은 안내일 뿐 신뢰 경계가 아닙니다. 최종 판정은 저장 직전의 서버 검증입니다.

검증기는 React에 의존하지 않으므로 다른 프레임워크·서버 코드에서도 그대로 씁니다 — 실제로 이
저장소는 같은 규칙 값을 화면과 Server Action이 함께 봅니다.

React 없이 쓰기#

디자인 레이어는 프레임워크에 의존하지 않습니다. 토큰도 컴포넌트 스타일도 순수 SCSS이고,
클래스명이 fox- 접두사 + BEM이라 전역에 풀어도 충돌하지 않습니다. Vue·Svelte·서버
템플릿·순수 HTML 어디서든 위 2~4번만 하고 마크업 계약을 지키면 같은 결과가 나옵니다.

각 컴포넌트의 마크업 계약은 해당 스타일 파일 상단 주석에 있습니다. 버튼 네 종류는 이렇습니다.

<button class="fox-button fox-button--primary fox-button--md">
  <span class="fox-button__icon"><!-- svg (선택) --></span>
  <span class="fox-button__label">버튼</span>
  <span class="fox-button__icon"><!-- svg (선택) --></span>
</button>

<button class="fox-text-button fox-text-button--primary fox-text-button--md">
  <span class="fox-text-button__label">텍스트 버튼</span>
</button>

<a href="…" class="fox-link-button fox-link-button--primary fox-link-button--md">
  <span class="fox-link-button__label">링크</span>
  <span class="fox-link-button__icon"><!-- svg (선택, 항상 라벨 뒤) --></span>
</a>

<button class="fox-icon-button fox-icon-button--ghost fox-icon-button--md" aria-label="공유">
  <span class="fox-icon-button__icon"><!-- svg --></span>
</button>

<div class="fox-button-group fox-button-group--horizontal fox-button-group--start">
  <button class="fox-button fox-button--default fox-button--md">취소</button>
  <button class="fox-button fox-button--primary fox-button--md">확인</button>
</div>

<div class="fox-button-panel fox-button-panel--offset">
  <div class="fox-button-group fox-button-group--horizontal fox-button-group--start">…</div>
  <div class="fox-button-group fox-button-group--horizontal fox-button-group--end">…</div>
</div>

<div class="fox-input fox-input--md">
  <label class="fox-input__label" for="name">레이블</label>
  <div class="fox-input__box">
    <input class="fox-input__field" id="name" placeholder="내용을 입력하세요.">
    <span class="fox-input__icon"><!-- svg (선택) --></span>
  </div>
  <p class="fox-input__message"><!-- 헬퍼 (선택) --></p>
</div>

<div class="fox-phone-number" role="group" aria-labelledby="phone-label">
  <span class="fox-phone-number__label" id="phone-label">전화번호</span>
  <div class="fox-phone-number__box">          <!-- combine -->
    <input class="fox-phone-number__segment" aria-label="전화번호 앞자리">
    <span class="fox-phone-number__separator">-</span>
    <input class="fox-phone-number__segment" aria-label="전화번호 가운데자리">
    <span class="fox-phone-number__separator">-</span>
    <input class="fox-phone-number__segment" aria-label="전화번호 뒷자리">
  </div>
</div>

<div class="fox-text-area">
  <label class="fox-text-area__label" for="memo">레이블</label>
  <div class="fox-text-area__box">
    <textarea class="fox-text-area__field" id="memo" rows="5" placeholder="내용을 입력하세요."></textarea>
  </div>
  <div class="fox-text-area__footer">
    <p class="fox-text-area__message"><!-- 헬퍼 (선택) --></p>
    <p class="fox-text-area__counter">
      <span class="fox-text-area__counter-current">0</span>/100
    </p>
  </div>
</div>
  • 비활성은 네이티브 disabled 속성으로 표현합니다(별도 클래스 없음). 앵커에는 disabled가
    없으므로 aria-disabled="true"를 쓰고 href를 뺍니다.
  • 로딩은 앞 아이콘 자리에 fox-button__spinner를 두고 disabled를 함께 겁니다.
  • 토글은 fox-icon-button에 aria-pressed="true|false"를 더합니다.
  • 버튼 묶음은 간격·방향·정렬만 담당합니다. 자식 버튼의 크기 클래스를 그룹 크기로 맞추는 건
    작성자 몫입니다(React 래퍼는 size prop으로 자동화합니다). 세로(--vertical)는 자식이
    폭을 채웁니다.
  • 버튼 패널은 그룹이 여럿이면 양끝으로, 하나면 가운데로 둡니다 — 자식 수는 CSS :only-child가
    판단하므로 따로 표시할 게 없습니다. 패널 안에서는 그룹 간격이 넓어지고 버튼에 최소 폭이
    붙으며, 모바일에서는 패널·그룹이 모두 세로로 쌓입니다.
  • 입력과 여러 줄 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 :focus-within,
    비활성·읽기전용은 네이티브 속성, 오류는 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를 주면 그 값을, 없으면
    expiresIn부터 컴포넌트가 셉니다.
  • 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지
    않습니다. unit은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, combine은
    상자 하나에 세 칸을 둡니다.
  • 파일 첨부는 세 모드(default·area·image)를 한 컴포넌트로 둡니다. 업로드는 하지
    않고 고른 원본 File만 넘기며, 진행률·서버 오류를 목록에 어떻게 비출지는 files로
    정합니다. area는 끌어다 놓기가 항상 켜져 있고 image에는 목록이 없습니다.
  • 아이콘 SVG는 currentColor로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다.

core/의 React 컴포넌트는 이 클래스를 조립해주는 얇은 래퍼일 뿐입니다 — 쓰지 않아도
디자인은 그대로 얻습니다.

토큰만 쓰는 것도 물론 됩니다.

@use "@fox/styles/abstracts" as fox;

.whatever {
  padding: fox.padding(6);
  color: fox.color(font-neutral-default);
  @include fox.pc { padding: fox.padding(8); }
}

핵심 설계#

토큰의 SSOT는 tokens/의 SCSS map 하나입니다. 여기서 두 가지가 함께 파생됩니다 —
_root.scss가 만드는 --fox-* 커스텀 프로퍼티와, _functions.scss가 검증에 쓰는 유효
이름 목록. 값과 이름이 한 곳에만 있으므로 둘이 어긋날 수 없습니다.

토큰은 Figma에서 자동 생성됩니다. 손으로 고치지 않습니다. Figma 변수를 재수출한 뒤
변환기를 다시 돌리면 됩니다. 생성물이 재현 가능해서 재실행 diff가 곧 Figma 변경분입니다.

FOX_TOKENS_SRC=<export 폴더> python3 @fox/tools/build-tokens.py

아이콘도 같은 방식으로 생성됩니다. core/icons/의 1,512종은 손으로 그리지 않았고
tools/build-icons.py가 Phosphor Icons(MIT)
원본에서 만듭니다. 시안의 ico/*가 Phosphor를 그대로 올린 것이라 그림이 같습니다 — 기존
8종과 새 생성물을 256×256으로 래스터 비교했을 때 차이가 잉크의 0.33% 이하(안티에일리어싱
경계)였고 Pause는 0픽셀이었습니다.

FOX_ICONS_SRC=<phosphor-icons/core 체크아웃> python3 @fox/tools/build-icons.py

굵기는 이름이 아니라 weight prop입니다. 시안의 Weight= 변형과 1:1로 맞습니다.

import { FoxHouseIcon, FoxPlayIcon } from "@fox/core/icons";

<FoxHouseIcon />                  {/* 기본 regular */}
<FoxPlayIcon weight="fill" />     {/* thin · light · regular · bold · fill · duotone */}

⚠️ 시안이 굵기를 지정한 자리에서 weight를 빠뜨리면 조용히 다른 그림이 됩니다. 특히
Play·Pause는 시안이 fill이라, 기본값 regular로 두면 속이 빈 도형이 됩니다.

배럴이 크지만 프로덕션 번들에는 실제 쓰는 글리프만 들어갑니다. dev 빌드는 트리셰이킹을
하지 않아 청크가 커집니다.

규율을 컴파일러가 강제합니다. 화면 코드는 color: #256EF4를 쓸 수 없고
fox.color(...)로 토큰을 지목해야 합니다. 없는 이름은 빌드가 실패하며 사용 가능한 목록을
함께 출력합니다.

Error: [@fox] 알 수 없는 color 토큰: `primry` — 사용 가능한 값: background-default, ...

1rem = 10px입니다. _root.scss의 html { font-size: 62.5% }가 근거입니다.

⚠️ 미디어 쿼리 안의 rem은 예외입니다. 미디어 쿼리는 html의 font-size 영향을
받지 않습니다. 그래서 브레이크포인트는 768px로 두었고, 미디어 쿼리는 fox.pc /
fox.mobile 믹스인으로만 씁니다 — 조건부는 var()를 해석하지 못합니다.

라이트/다크는 값이 한 곳에만 존재합니다. light-dark(라이트, 다크)로 한 줄에 두 값을
쓰고 전환은 color-scheme이 담당합니다. 다크 전용 오버라이드 블록이 없어 테마 불일치가
구조적으로 불가능합니다. <html data-theme="light"|"dark">로 수동 선택을 덮어쓸 수 있고,
속성이 없으면 OS 설정을 따릅니다.

light-dark() 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이 필요하지만,
Lightning CSS(Next.js Turbopack)가 커스텀 프로퍼티 조합으로 폴리필해 출력하므로 구형
브라우저에서도 동작합니다. browserslist 타깃을 좁히거나 다른 번들러로 옮길 때 재확인하세요.

호스트 앱과의 계약#

폰트 파일은 앱이 소유합니다. @fox는 폰트를 담고 있지 않으므로, 앱이 아래를 공급하지
않으면 브라우저 기본 서체로 그려집니다.

앱이 공급할 것쓰이는 곳미공급 시
@font-face { font-family: "Pretendard" }@fox 컴포넌트 전부OS 기본 서체(맥 Apple SD Gothic Neo / 윈도 맑은 고딕)로 갈라짐
--app-font-sans@fox 밖 앱 텍스트앱이 알아서 폴백
--app-font-mono고정폭이 필요한 앱 텍스트앱이 알아서 폴백

⚠️ @fox 컴포넌트의 서체는 --app-font-sans가 아닙니다. 토큰
(tokens/_primitive.scss의 font.family.*)이 패밀리명을 리터럴 Pretendard로 못 박고
있어, 그 이름의 @font-face를 앱이 선언해 줘야 합니다. 폰트를 바꾸려면 Figma 원본 토큰을
고쳐 재생성하는 수밖에 없습니다 — 즉 이 지점만은 이식성이 없습니다.

필요한 굵기는 토큰이 쓰는 400 / 600 / 800 세 개입니다. 800을 빼면 font-weight(bold)가
가짜 굵기로 합성됩니다.

next/font/local은 패밀리명을 해시(__Pretendard_xxxx)로 바꾸므로 이 용도로는 쓸 수
없습니다. 이 저장소는 app/globals.scss에 @font-face를 직접 선언합니다.

새 컴포넌트 추가#

core/components/README.md의 작성 규약을 따릅니다. 요점은 스타일을 styles/_<name>.scss에
두고 fox- 접두사 + BEM으로 이름 짓는 것 — 그래야 React 밖에서도 쓸 수 있습니다.