Skip to content

디자인 시스템(프론트엔드)

2026-08-13 확인 · 결정 v3-77, v3-129

프론트엔드 디자인 시스템은 **공유 워크스페이스 패키지 하나(@aset/ds-editorial)**에 있고 두 앱(apps/web, apps/admin-web)이 이를 가져다 쓴다. 폰트, 색상, 타입 스케일, 시맨틱 토큰, 패턴 컴포넌트, 쇼케이스 모두 거기 하나의 출처를 갖는다. 그래서 폰트나 색을 바꾸는 것이 앱마다 CSS가 갈라지는 대신 파일 하나 수정으로 끝나고 두 앱이 함께 반영된다.

이 페이지는 백엔드와 기획이 구조를 보고 확인할 수 있게 두었다. 앱별 로직이 아니다.

무엇이 어디 있나

대상파일(packages/ds-editorial/src/ 기준)import 경로
폰트 로딩(@import url() Google Fonts)fonts.css@aset/ds-editorial/fonts.css
: 팔레트, 타입 스케일, font-family 토큰, shadcn 시맨틱 --color-*tokens.css@aset/ds-editorial/tokens.css
컴포넌트: 정본 패턴 5종과 cnpatterns.tsx(index.ts 경유)@aset/ds-editorial
쇼케이스: 살아 있는 /styleguide 페이지showcase.tsx@aset/ds-editorial/showcase
  • 폰트: Quicksand(산세리프)와 DM Serif Display(디스플레이·세리프). 세리프는 디스플레이 수치(20px 이상의 stat/lead/hero 숫자)**와 페이지·섹션 제목(H1)**에 쓴다. 어드민 페이지 헤더("Create Pool", "Yield")가 그 예다. 밀도 높게 읽는 텍스트는 산세리프를 유지한다.

    ⚠️ Google Sans는 마케팅 사이트의 폰트이지 제품의 폰트가 아니다. apps/landing/global.css--font-sans를 Google Sans로 덮어쓰는데 그 사이트 안에서만 그렇고, 그 폰트는 web과 admin에 아예 로드되지 않는다. 앱의 제목 폰트는 DM Serif Display다. 어드민의 인증 카드(login, super-login, totp-step, fund-invite-accept)가 유일하게 맨 CardTitle을 쓰고 있어서 Quicksand를 물려받았고, 다른 모든 어드민 페이지 제목 옆에서 스타일이 안 먹은 것처럼 보였다. 지금은 page-header.tsx와 맞춰 font-serif font-semibold를 쓴다.

  • 타입 스케일: text-{label,data,read,stat,lead,dstat,hnum,hero}다. 읽기용 세 크기는 12 · 14 · 16으로 고정이고, 디스플레이용 다섯 크기는 유동적이되 줄어들기만 한다. stat 18→20, lead 20→24, dstat 24→32, hnum 28→40, hero 32→48이고 각각 375→1280px 뷰포트 구간에서 변한다(v3-129).

    ⚠️ 유동 토큰은 apps/landing에도 닿는다. 같은 tokens.css를 import하기 때문이다. 거기서 text-lead / text-stat를 쓰는 호출 지점이 14곳이라, 본문이 24 / 20으로 고정돼 있던 것이 이제 20→24px, 18→20px로 움직인다. 그 사이트 자체의 --text-display-* clamp와는 일관되지만, 앱만의 변경이 아니라 마케팅 사이트 변경이기도 하다. 원치 않으면 apps/landing/global.css의 자체 @theme에서 토큰을 다시 고정하면 된다(이미 --font-sans를 그렇게 덮어쓰고 있고, 그 @theme이 이 패키지 것보다 나중에 import되므로 이긴다).

  • 팔레트: brand / info / lavender / lilac / success / error / mist / neutral / navy와, Aset 브랜드에 매핑한 shadcn 시맨틱 토큰이다. brand는 #5423e7다.

  • 패턴: Stat, Meter, Fact line, Waterfall, Ledger다. 평면적이고 헤어라인 위주이며 과도한 박스를 피한다(카드나 채움을 쓰지 않는다).

import 순서(중요하다)

각 앱의 global.css는 정확히 이 순서로 import해야 한다.

css
/* apps/web/global.css */
@import '@aset/ds-editorial/fonts.css';   /* 1. 폰트 로딩 — 반드시 맨 앞 */
@import '@aset/ds-editorial/tokens.css';  /* 2. 토큰 */
@import 'tailwindcss';                     /* 3. */
css
/* apps/admin-web/global.css */
@import '@aset/ds-editorial/fonts.css';                /* 1. 폰트 로딩 — 반드시 맨 앞 */
@import '@shard-lab/finance-dashboard-kit/styles';     /* 2. 킷 */
@import '@aset/ds-editorial/tokens.css';               /* 3. 킷 다음 — Aset 팔레트가 이긴다 */
@import 'tailwindcss';                                  /* 4. */

순서 규칙 둘

  1. fonts.css가 맨 앞이어야 한다. CSS는 @import url()이 실제 규칙보다 앞에 오도록 요구하는데, 어드민은 실제 규칙이 있는 킷을 로드한다. 그래서 폰트 @import를 분리해 맨 앞에 두어야 유효하다.
  2. 어드민에서는 tokens.css가 킷 다음에 와야 한다. @theme 블록은 나중 것이 이기도록 병합되므로, finance-dashboard-kit 다음에 import해야 Aset 팔레트가 킷 기본값을 덮는다. 이것이 어드민 색상을 web과 통일해 준다.

결과적으로 web과 admin이 동일한 팔레트 하나로 렌더된다. 각 global.css에는 앱 로컬 항목만 남는다(web은 radius와 shadcn :root HSL 삼중값, admin은 킷의 사이드바 토큰).

브레이크포인트와 페이지 컬럼 v3-129

Tailwind 기본 브레이크포인트를 그대로 쓴다. --breakpoint-*를 덮어쓰지 않는데, 재정의하면 세 앱의 모든 기존 md:/lg:가 조용히 움직이기 때문이다. 여기 이름을 적어 두는 것은 제품이 원시 숫자가 아니라 기기 분류로 사고하기 때문이다.

단계기기 분류여기서 바뀌는 것
base<640
sm≥640큰 폰 · 작은 태블릿
md≥768태블릿web이 하단 탭을 헤더 내비로 바꾼다
lg≥1024노트북admin이 드로어를 고정 레일로 바꾼다
xl≥1280데스크톱web의 여백이 더 이상 커지지 않는다(64px)
2xl≥1536모니터컬럼이 넓어진다. 아래 참조

모니터 단계에서 web과 admin이 정반대로 움직인다. 여기가 거꾸로 알기 쉬운 부분이다.

  • web은 컬럼을 80rem(1280px)으로 제한하고 있어서, 2560px 디스플레이에서 절반을 빈 여백에 쓰고 있었다. 이제 --container-monitor(105rem = 1680px)로 넓어진다. 1680은 임의의 숫자가 아니다. 이미 쓰던 368px 브라우즈 카드 네 장이다.

    ⚠️ 브라우즈 그리드는 lg 위로 뷰포트 브레이크포인트를 쓰지 않는다. AppShelllg 이상에서 라우팅된 콘텐츠 왼쪽에 240px 사이드바를 두고, 그 사이드바는 런타임에 접힌다. 그래서 카드가 차지하는 공간이 뷰포트가 아니다. 뷰포트를 기준으로 컬럼을 나누던 모든 단계가 같은 방향으로 틀렸다. xl:grid-cols-3은 컬럼이 912px일 때 1280에서 발동해 288px 카드를 만들었고, 2xl:grid-cols-4274px를 만들었다. 둘 다 이전 컬럼 수가 이미 주던 368px보다 좁다. 그래서 그리드를 내재적 방식으로 바꿨다.

    grid-cols-1 sm:grid-cols-2 lg:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]

    실제로 들어가는 만큼 300px 이상 트랙을 만들므로, lg 이상에서 카드가 300 아래로 내려가지 않는다(routes/pools.tsxBROWSE_GRID이고 스켈레톤과 로드된 목록이 공유한다). auto-fit이 아니라 auto-fill이다. 풀이 트랙 수보다 적으면 auto-fit은 빈 트랙을 접고 카드 둘을 각 760px 정도로 늘린다. 폰과 태블릿은 뷰포트가 폭이므로 명시적인 1단·2단 단계를 유지한다.

    300px이 유일한 조절 값이고, 최소 카드 폭과 컬럼이 얼마나 일찍 생기는지를 맞바꾼다. 올리면 옛 사다리 대비 컬럼을 잃는 구간이 넓어지고, 내리면 이 작업이 고치려던 288px에 가까워진다. 현재 설정은 300이고, 컬럼을 잃는 구간을 좁게 유지하려고 고른 값이다.

    하한lg 이상 최소 카드컬럼을 잃는 구간4번째 컬럼 시작
    300px3001280~13151640px
    330px3301280~14051760px
    368px3681024~15191912px

    항목 수가 고정된 그리드는 평범한 컬럼을 유지한다. portfolio의 "Discover more"는 .slice(0, 3)으로 렌더하므로, 채울 수 없는 auto-fill 트랙은 카드 셋을 좁히고 자리 하나를 비워 둘 뿐이다.

  • admin은 상한이 아예 없었다. main이 창 끝까지 갔다. 목록 라우트가 넓은 표라(Pools는 컬럼이 일곱이다) 같은 디스플레이에서 한 행이 2496px쯤 늘어났고, 맨 왼쪽의 풀 이름과 맨 오른쪽의 체인을 눈으로 잇는 일이 됐다. 이제 같은 --container-monitor 상한을 갖는다.

토큰 하나로 두 방향이니 이 쌍이 하나의 결정으로 남는다. 어드민의 상세·생성·수정 라우트는 자기들의 더 좁은 max-w-[1200px]을 유지하고, 그것이 상한 안에서 여전히 이긴다.

web의 컬럼은 컴포넌트가 아니라 유틸리티다. page-shell(apps/web/global.css)이 가운데 정렬, 여백 램프, 상한을 소유한다. 라우트 아홉 곳에 복붙돼 있던 같은 문자열과, 헤더·푸터·스켈레톤에 있던 더 좁은 두 번째 변형을 대체했다. 램프가 둘이면 xl에서 헤더 로고가 콘텐츠 왼쪽 경계보다 32px 안으로 들어갔다. 아래 페이지와 줄이 안 맞는 셸이었던 것이다. 세로 리듬은 일부러 유틸리티에 넣지 않았다. 라우트마다 정당하게 다르다(평소에는 py-10 lg:py-12, 빈 상태는 py-16, 하단 내비가 겹치는 곳은 pt-10 pb-24). 라우트 하나(pool.$id)는 컬럼을 직접 적는데, lg 이상에서 왼쪽 정렬을 하고(lg:mx-0) 유틸리티는 무조건 가운데 정렬을 하기 때문이다.

킷 컴포넌트 안에서는 tailwind-merge가 이 토큰들을 조용히 지운다

모든 킷·shadcn 컴포넌트가 cn(...) = twMerge(clsx(...))로 클래스 목록을 조합한다. tailwind-merge의 기본 설정은 자기 폰트 크기 스케일(text-sm, text-2xl, text-[13px] 등)만 인식한다. text-lead 같은 커스텀 테마 키는 그중 어느 패턴에도 맞지 않아 text-color로 분류되고, 같은 className에 실제 색상이 함께 있는 순간 둘이 "충돌"해 나중 것이 이긴다.

twMerge('text-lead font-bold text-neutral-700')  →  'font-bold text-neutral-700'   ← 크기가 사라짐
twMerge('text-2xl font-bold text-neutral-700')   →  그대로                          ← 내장 스케일은 살아남음

이 저장소는 세 곳 중 두 곳에서 이미 해결했다. packages/ds-editorial/src/cn.tsapps/web/app/shared/lib/utils.ts 둘 다 extendTailwindMergecn을 만들면서 토큰 여덟 개를 font-size에 등록한다.

ts
const twMerge = extendTailwindMerge({
  extend: { classGroups: { 'font-size': [{ text: ['label','data','read','stat','lead','dstat','hnum','hero'] }] } },
});

그래서 공유 패턴 컴포넌트와 web의 모든 shadcn 컴포넌트는 올바르게 병합하고, 거기서는 토큰이 안전하다.

⚠️ apps/admin-web에는 자체 cn이 없다. Card / Dialog / Button@shard-lab/finance-dashboard-kit에서 오는데, 그 패키지는 평범한 twMerge를 번들하고 병합을 패키지 안에서 한다. 그래서 어드민 쪽에 확장된 cn을 둬도 참조되지 않는다. 노출 지점은 그것뿐이다. 어드민의 킷 컴포넌트, 그 외에는 없다.

그 경우에는 크기를 길이로 적는다. 그러면 평범한 tailwind-merge도 font-size로 분류한다.

text-(length:--text-lead) leading-tight font-bold text-neutral-700

이 형태는 font-size 담는다. 짝인 --text-*--line-height는 따라오지 않으므로 leading-*를 명시해야 한다.

이미 가드가 있는데도 따로 적어 두는 이유는 이 실패가 모든 게이트에 보이지 않기 때문이다. 어드민 CardTitle 일곱 개가 물려받은 크기(24px → 16px)로 렌더됐는데 tsc -b도, 두 프로덕션 빌드도, 문구 가드도 전부 통과였다(DialogTitle 둘은 이 작업 전부터 같은 방식으로 18px에서 깨져 있었고 같은 수정으로 함께 고쳐졌다). 클래스는 유효하고 컴파일되며 CSS에도 들어 있다. 런타임에 지워질 뿐이다. 실제 브라우저에서 getComputedStyle로 측정한 것만이 이를 잡아냈고, 토큰을 달고 있는 요소를 찾는 DOM 스캔으로는 잡을 수 없다. 크기를 정해 주려던 그 요소에서 클래스가 사라져 있기 때문이다.

⚠️ 기존 사례(어드민만): pool-controls.tsx:639nav-change-dialog.tsx:173이다(DialogDescription에서 text-read가 사라져 16px 대신 14px로 렌더된다). web의 PoolStatusAlert.tsx:216-217은 똑같아 보이지만 영향이 없다. web의 확장된 cn을 거치기 때문이다.

램프와 단발 값이 만날 때는 축 단위 유틸리티를 쓴다

Tailwind는 변형을 기본 유틸리티 뒤에 내보내므로, p-4 sm:p-6 lg:p-8 pb-20pb-20을 유지하지 못한다. sm 위의 모든 폭에서 반응형 p-*가 이겨 하단 패딩이 조용히 램프 값으로 줄어든다. 대신 축마다 램프를 준다. px-4 pt-4 pb-20 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8처럼 쓴다(어드민 page-layout.tsx).

토큰을 바꾸는 방법

  • 색이나 폰트를 바꾼다: packages/ds-editorial/src/tokens.css를 한 번 수정하면 다음 빌드에 두 앱에 반영된다.
  • 폰트 로딩을 바꾼다(웨이트 추가, 패밀리 교체): fonts.css(로딩)와 tokens.css의 해당 --font-* 토큰을 함께 수정한다.
  • 패턴 컴포넌트를 바꾼다: patterns.tsx를 수정하면 두 앱과 /styleguide가 함께 갱신된다.

살아 있는 스타일가이드: /styleguide

showcase.tsx는 사본이 아니라 실제 토큰과 패턴 컴포넌트를 렌더하므로 어긋날 수 없다. 인증 밖에(데이터 없이) 두 앱 모두에 마운트돼 있다.

  • web → /styleguide
  • admin → /styleguide

두 앱이 팔레트 하나를 공유하므로 이 페이지는 어느 쪽에서 봐도 똑같아 보인다. 양쪽에서 열어 보는 것 자체가 일치 여부 확인이 된다.

폐기된 참조

쓰지 말 것. `/styleguide`로 대체됐다

손으로 만든 옛 디자인 참조 둘이 이 값들을 중복해서 담고 있었고 이미 어긋났다(교체 전 Fraunces/Inter 폰트를 여전히 보여 준다). 폐기됐다. 앱 안의 /styleguide를 쓸 것.

  • claude.ai 아티팩트 "Pool Detail — Component Vocabulary"
  • Google Drive의 emergefi-color-system.html

검증

두 앱 모두 공유 패키지로 빌드되고, 두 번들 다 폰트를 로드하고 브랜드 색을 해석한다.

pnpm --filter @aset/web build
pnpm --filter @aset/admin-web build

build/client/assets/*.css에서 확인한 것: Quicksand와 DM Serif Display의 @import가 있고, --font-sans가 Quicksand이며, 브랜드 #5423e7이 있고, /styleguide 라우트 청크가 생성된다.