시안(Figma 디자인시스템)이 Pretendard를 지정하는데 앱 셸만 WantedSans를 쓰고 있어 한 화면에
두 서체가 섞여 있었다. 앱도 Pretendard로 맞춘다.
- layout.tsx: `next/font/local`의 wantedSans 선언을 걷어냈다. 앱 기본 서체는 이제 globals.scss의
`@font-face`가 공급한다 — @fox 토큰이 패밀리명을 리터럴 `Pretendard`로 못 박고 있어
next/font(패밀리명을 해시로 바꾼다)를 쓸 수 없기 때문이고, 같은 서체를 두 경로로 넣을 이유도
없다.
- globals.scss: `--app-font-sans: "Pretendard"`를 @font-face 옆에 정의했다. @fox README가 정의한
호스트 앱 계약의 실제 주입 지점이라, 나중에 서체를 갈 때 이 한 줄만 고치면 된다.
- weight 700(Bold) 추가. 토큰이 쓰는 400·600·800만으로는 어떤 CSS도 굵기를 지정하지 않는
자리(제목·th·strong 등 UA 기본값 700)가 800으로 올라가 굵어진다.
- public/fonts: WantedSans TTF 4종(9.4MB) 삭제. 참조가 layout.tsx 한 곳뿐이었고 그게 사라졌다.
검증 — `--app-font-sans`가 `"Pretendard"`, body 계산값이 `Pretendard, system-ui, sans-serif`,
document.fonts에 wantedSans가 아예 없다. 굵기별 문자열 폭이 400/600/700/800에서 각각
328.2 / 335.2 / 338.7 / 342.2px로 전부 달라, 700이 800으로 승격되지 않고 실제 Bold 페이스로
잡히는 것까지 확인했다. FoxCarouselIndicator는 59.69px로 종전과 같다(회귀 없음).
저장소 폰트 용량 11.6MB → 3.0MB.
Co-Authored-By: Claude Opus 5
2단계 줄에 `-`와 점이 함께 나왔다. 항목이 항목을 품는 구조인데 단계별 규칙을 후손
선택자로 적어, `.--lv1 .__marker`가 그 안에 중첩된 2·3단계 마커에도 걸렸다.
단계별 규칙을 자식 결합자로 바꿔 자기 줄만 고르게 했다.
`.--lv1 > .__row > .__marker`
카탈로그의 34개 항목을 전부 훑어 글자와 도형이 함께 있는 줄이 없음을 확인했고,
여섯 조합이 시안대로 하나씩만 나온다 — 점 6px / `-` / 빈 원 4px / `1.` / `a.` / `①`.
시안 대조 20항목도 그대로 통과한다.
Co-Authored-By: Claude Opus 5
Figma bullet(391:4049) · bullet-list-item(391:4081) · bullet-list(484:5722)를
FoxBulletList / FoxBulletListItem 둘로 옮겼다.
bullet을 따로 두지 않은 이유는 마커가 목록 밖에서 홀로 쓰이는 자리가 없고, 모양이
전부 단계·번호 여부로 갈리기 때문이다. 그 두 축이 항목의 수식어로 들어가 있다.
호출부는 단계를 평평하게 늘어놓고 level만 적는다. 어느 항목 안에 무엇을 넣을지는
목록이 정한다.
레벨1레벨2
시안은 2·3단계를 형제 프레임으로 두고 왼쪽 여백만 20·40px 줬지만, 나가는 마크업은
/ 안에
/이 든 진짜 중첩이다 — 목록의 단계를 스크린리더에 알리려면
그래야 한다. 한 단계마다 20px씩 들어가 3단계가 40px이 되므로 서는 자리는 시안과 같다.
앞에 걸어 둘 항목이 없는 경우(첫 줄부터 2단계)는 빈
에 매달아 목록을 유효하게 둔다.
번호는 같은 묶음 안에서 1부터 다시 센다. 시안에 없는 범위는 z 다음 aa, ⑳ 다음 (21)로
잇고, 다르게 매겨야 하면 marker 프롭으로 덮는다.
승인된 비토큰 값: 마커 칸 2.2×2.6rem · 1.6×2.3rem · 2.4×2.3rem, 채운 원 0.6rem,
빈 원 0.4rem — 모두 시안 값이나 Figma가 변수로 export하지 않는다.
검증: 시안 대조 20항목 전부 일치(칸 크기·마커 모양·글자 크기·단계별 들여쓰기
0/20/40px). 목록의 자식이 li뿐이고 중첩 목록은 모두 li 안에 있다.
sass 463규칙 · tsc --noEmit · eslint 통과.
Co-Authored-By: Claude Opus 5
아이콘이 없어 텍스트로 대체돼 있던 자리를 시안대로 되돌린다. `@fox`에 Phosphor 전량이
들어왔으므로 더 이상 막힐 이유가 없다.
- students: 돋보기(FoxMagnifyingGlassIcon) — "조회" 텍스트 버튼을 대체
- admins · boards · decoration-items: 연필(FoxPencilSimpleIcon)·휴지통(FoxTrashIcon)
- 테마 토글: 손으로 그린 monitor/moon/sun 3종을 @fox 아이콘으로 교체하고
components/ui/icons/ 디렉터리를 제거했다(다른 사용처 없음)
버튼을 `components/ui/button`이 아니라 `FoxIconButton`으로 쓴 이유 — @fox 아이콘은 스스로
크기를 갖지 않고 감싼 슬롯이 정하는 규약인데, 그 슬롯 크기를 실제로 정의하는 CSS가 있는 건
@fox 쪽뿐이다. Tailwind 제거(e3d3133)가 빌드 설정만 바꾸고 components/ui 19개 파일은 그대로
둔 탓에 `size-9`·`inline-flex` 같은 클래스가 전부 무효라, 그쪽에 아이콘을 넣으면 SVG가 기본
300×150으로 그려진다.
테마 토글은 같은 이유로 실제 버그였다 — 표시 전환을 Tailwind의 `hidden theme-*:contents`에
기대고 있어서 세 아이콘이 한꺼번에 보이고 있었다. 같은 동작을 CSS 모듈로 다시 세웠고,
`aria-label` 대신 `sr-only` 텍스트로 현재 선택을 읽히는 기존 접근성 설계는 그대로 유지했다
(IconButton에 label을 넘기면 aria-label이 내용을 덮어쓴다).
검증 — 컴파일된 CSS를 주입해 `` 3상태를 실제로 전환했다. 속성 없음→system,
light→light, dark→dark로 **매번 하나만** 표시되고 20×20px(--fox-icon-4)로 그려진다.
FoxIconButton은 sm/ghost에서 28px 버튼에 24×24 아이콘이다.
남은 것 — sidebar-group.tsx의 셰브론은 손으로 그린 채로 뒀다. 회전(`rotate-90`)과
크기(`size-4`)를 Tailwind 클래스에 기대고 있어 그 둘을 실재하는 CSS로 옮기기 전에는 글리프만
바꿔도 나아지지 않는다. components/ui 전체 리스타일과 함께 처리한다.
Co-Authored-By: Claude Opus 5
develop에서 온 FoxFormLabel과 내가 두었던 form-field의 label 믹스인이 같은 일을
두 갈래로 하고 있었다. 라벨 모양을 아는 곳을 하나로 줄인다.
- FoxFormLabel에 `as` 프롭 추가 — 칸 하나를 가리킬 때는 label, 칸 여러 개를 묶은
필드에서는 span. 가리킬 대상이 하나가 아니라 htmlFor를 쓸 수 없고, 아무것도
가리키지 않는
FoxFormLabel · FoxRadio(+Icon·Group) · FoxConditionalOption · FoxCheckbox(+Icon·Group)
· FoxTag(+List) · FoxToggleSwitch가 들어왔다.
충돌 2건은 양쪽을 모두 살리는 union으로 해소했다 — 둘 다 "컴포넌트를 만들 때마다
항목 하나를 추가하는" 등록 파일이라 서로 지울 이유가 없다.
- @fox/styles/components.scss: 새 파티셜 10개 + Navigation 파티셜 5개
- @fox/dev-test/component-registry.tsx: 카탈로그 항목 32개
검증: sass 컴파일 450규칙 · tsc --noEmit · eslint 모두 통과.
Co-Authored-By: Claude Opus 5
시안의 `ico/*`는 Phosphor Icons를 그대로 올린 것이다. `ico/House` Regular와 Phosphor
`house.svg`는 문 너비 16.67%·문 span 25%·벽 두께 8.33%가 전부 같고, 프레임 구조도
`Format=Stroke|Outline × Weight=Thin|Light|Regular|Bold|Fill|Duotone`으로 Phosphor의
weight 체계 그대로다. 그래서 Figma를 1,512번 호출하는 대신 원본에서 생성했다.
- @fox/tools/build-icons.py 신설. build-tokens.py와 같은 자리·같은 철학(Figma가 SSOT이고
생성물은 재현 가능)이다. 실행 1.4초.
- core/icons/glyphs/*.tsx 1,512개 + fox-icon.tsx(공용 껍데기) + index.ts 배럴.
기존 icons.tsx(8종 수기 이식)는 이 안에 포함되므로 삭제했다.
- 굵기를 이름이 아니라 `weight` prop으로 바꿨다. 시안의 `Weight=` 변형과 1:1이다.
FoxCaretLeftBoldIcon → .
- viewBox를 Phosphor 원본의 256으로 통일했다. 기존 8종은 Figma export라 16/24 박스에
translate가 붙어 있었지만 같은 그림을 축소한 것이다(House bbox x=32 → 32/256×16=2로
기존 translate와 일치).
검증 — 기존 8종과 새 생성물을 256×256으로 래스터화해 픽셀 비교했다. 차이가 잉크의 0.33%
이하(안티에일리어싱 경계)였고 Pause는 0픽셀이었다. weight 매핑이 유일한지도 오답을 넣어
확인했다: Play를 regular로 두면 58.4%, bold면 60.7%, Plus를 regular로 두면 34.0% 어긋난다.
즉 시안의 Play·Pause는 fill, Plus는 bold다 — 이름에 표시가 없어 놓치기 쉬운 지점이라
fox-carousel-button 주석과 README에 명시했다.
번들 — 배럴이 크지만 프로덕션에는 쓰는 글리프만 들어간다(안 쓰는 아이콘의 path 문자열이
프로덕션 .js 어디에도 없음을 확인). build 6초, tsc 3초로 영향 없다.
라이선스 — Phosphor는 MIT다. 사본에 고지를 함께 두도록 요구하므로 생성기가 원본 LICENSE를
core/icons/PHOSPHOR-LICENSE.txt로 옮긴다.
Co-Authored-By: Claude Opus 5
@fox 토큰이 패밀리명을 리터럴 `Pretendard`로 지정하는데 프로젝트 어디에도 그 이름의 폰트
공급이 없었다. 그래서 모든 @fox 컴포넌트가 OS 기본 서체(맥 Apple SD Gothic Neo / 윈도
맑은 고딕)로 갈라져 그려졌고, 글자 폭이 시안과 어긋났다.
- public/fonts: Pretendard v1.3.9 woff2 400/600/800 반입(OFL 1.1). 토큰이 쓰는 weight가
정확히 이 셋이다 — 특히 800은 wantedSans에 없어 가짜 굵기로 합성되고 있었다.
- globals.scss: @font-face로 직접 선언. next/font/local은 패밀리명을 해시로 바꿔 토큰의
리터럴과 만나지 못해 이 용도로는 쓸 수 없다.
- globals.scss: body에 `--app-font-sans` 연결. 이 계약 변수를 읽는 곳이 없어 wantedSans가
로드만 되고 어디에도 적용되지 않았다. 이제 @fox 밖 텍스트가 wantedSans를 쓴다.
- next.config.ts: `/fonts/*`를 no-store에서 제외. public/ 자산이라 그대로 두면 페이지를
열 때마다 2.2MB를 다시 받는다.
- proxy.ts: 폰트 확장자를 matcher에서 제외. 세션 없는 방문자의 폰트 요청이 `/login`으로
리다이렉트돼 로그인 화면만 대체 서체로 그려졌다.
- @fox/README.md: "호스트 앱과의 계약"이 @fox 컴포넌트도 `--app-font-sans`를 읽는 것처럼
적혀 있었으나 사실이 아니다. 실제 요구사항(Pretendard @font-face·400/600/800)으로 교체.
FoxCarouselIndicator number형 56.92px → 59.69px. 시안 62px과의 잔여 2.31px은 Figma가 텍스트
상자 폭을 정수로 올리고(+1.57) 마지막 글자의 음수 자간을 빼지 않는(+0.75) 표기 차이다.
Co-Authored-By: Claude Opus 5
Figma Navigation 섹션(391:4035)의 7개 컴포넌트 타입을 5개로 옮겼다.
- FoxBreadcrumb (breadcrumb)
- FoxPagination (pagination + pagination-item)
- FoxCarouselIndicator (carousel-dot + carousel-number)
- FoxCarouselButton (carousel-arrow + carousel-atomic)
- FoxCarouselArea (carousel-area)
합친 근거는 껍데기가 같고 안에 든 것만 다르다는 것이다. 원형 버튼 둘은
테두리·그림자만 달라 type으로, 위치 표시 둘은 흰 알약이 같아 type으로 갈랐다.
pagination의 시안 device 축은 프롭이 아니라 화면 폭이 정한다 — 768px 미만에서
이전·다음이 첫 줄로 빠지고 번호가 아래 줄에 선다. 마크업은 한 벌이고 DOM 순서는
읽는 순서 그대로다.
@fox/core/icons 신설 — 손으로 그리지 않고 Figma export 자산의 path를 옮겼다.
시안이 아이콘을 정사각 박스 안에 여백을 두고 앉히므로 그 여백까지 translate로
재현했다. CaretLeft/Right는 Regular·Bold 두 벌이다(시안이 자리마다 다르게 물렸다).
승인된 비토큰 값: 숫자칸 3.2rem/2.4rem, 캐러셀 점 0.8rem, 활성 막대 2.4rem,
가운뎃점 0.4rem — 모두 시안 값이나 Figma가 변수로 export하지 않는다.
Co-Authored-By: Claude Opus 5
공지사항 등록 시 "A 'use server' file can only export async functions,
found object"로 500이 발생했다. boards/_actions.ts가 INITIAL_* 상태 상수를
함께 export한 것이 원인이다.
관리자 회원에서 같은 원인을 고친 방식(abb6259)에 맞춰 *FormState 타입과
INITIAL_* 상수를 lib/domain/board-post-form.ts로 옮겼다. 빌드·타입체크는
통과하고 폼을 실제로 제출해야만 드러나는 런타임 전용 제약이라, 근거를
domain 파일 주석에 남겼다.
Co-Authored-By: Claude Opus 5
충돌 2건 해결:
- lib/http/backend-fetch.ts: hub 버전 채택. hub가 이미 PUT/DELETE·form·
multipart를 갖췄고 data:null 성공을 canHaveNullData로 다루므로, 같은 목적으로
추가했던 backendCommand를 걷어내고 게시판 Repository를 그 규약에 맞췄다.
파일 업로드용 timeoutMs만 BackendRequestInit에 추가로 남겼다.
- admin-sidebar.tsx: 꾸미기아이템관리(hub)와 게시판관리(고객센터)를 함께 둔다.
Co-Authored-By: Claude Opus 5
사이드바 「게시판관리(고객센터)」 그룹과 3개 화면 신설
(/boards/notices, /boards/inquiries, /boards/faqs).
백엔드 최신화(develop 924db37)로 새로 생긴 관리자 게시판 API에 연동한다.
세 화면이 TB_COM_BBS 한 테이블을 stngId로만 구분하므로 도메인·Repository·
Server Action·공용 컴포넌트를 한 벌로 두고 BoardType으로 분기한다.
- 공지사항: 목록·등록·수정·삭제 (구분/사용여부 필터, 상단고정·노출기간·앱푸쉬)
- 1:1문의: 목록·상세답변·삭제 (등록 없음, 전화번호 마스킹, 진행상태)
- FAQ: 목록·등록·수정·삭제 (구분/유형 필터)
- 첨부파일 업로드(Server Action 경유)와 다운로드 중계 라우트 추가
백엔드 계약 확인에 따른 결정(사용자 확인 완료):
- totalCount·검색조건 한계로 전체를 받아 서버에서 검색·필터·페이징
- stngId·moduleId·구분/유형/진행상태 코드는 백엔드 미확정이라 상수 한 곳에
임시값을 두고 추후 교체(BOARD_SETTING_IDS 등)
- 유형은 저장 필드가 없어 임시 파라미터명으로 전송(백엔드 추가 예정)
- 노출기간·앱푸쉬는 조회 SELECT에 없어 전송만 하고 조회는 빈 값
- 본문은 리치 에디터 대신 글자수 카운트 textarea
backend-fetch: PUT/DELETE·폼 인코딩·멀티파트 지원 추가. 쓰기 API가
success(null)을 반환해 기존 backendFetch로는 성공이 실패로 읽히므로
데이터 없는 쓰기 전용 backendCommand를 분리했다.
Co-Authored-By: Claude Opus 5
두 레인이 같은 시기에 backendFetch에 form-urlencoded 전송을 추가해 lib/http/backend-fetch.ts가
충돌했다. 원인이 같다 — 백엔드의 쓰기 API가 @RequestBody가 아니라 @ParameterObject로 받아
JSON을 보내면 전 필드가 null로 저장된다(관리자 등록·수정, 아이템 등록·수정 모두).
아이템 쪽 구현이 관리자 쪽을 포함하므로 그것을 채택했다:
- method에 DELETE가 더 있다(아이템 삭제).
- form 값이 string 외에 number·undefined를 받는다(undefined는 전송에서 제외).
- multipart 본문을 지원한다(아이템 이미지 업로드).
- 본문·Content-Type 결정을 buildRequestBody 한 곳으로 모아 세 형식을 한눈에 볼 수 있다.
관리자 레인의 호출부는 Record을 넘기므로 넓어진 타입에 그대로 들어맞는다.
주석에는 두 도메인의 예시를 함께 남겼다.
백엔드(develop 924db37)에 관리자 쓰기 API가 추가되어 mock을 걷어낸다.
등록 POST /mngr/admin, 수정 PUT /mngr/admin, 중복확인과 단건 조회는 전용 GET을
쓴다. mock 저장소에는 삭제만 남는다 — 백엔드에 삭제 엔드포인트가 없고, 수정
API로도 USE_YN을 끌 수 없어 비활성화로 대신할 수 없다.
수정 시 비밀번호를 필수로 막는다. 백엔드 UPDATE가 LOGIN_PW를 조건 없이
덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 되기 때문이다.
메뉴 선택은 시안대로 유지하되 전송하지 않는다 — 관리자별 메뉴 권한 개념이
백엔드에 없다.
Co-Authored-By: Claude Opus 5