토큰
디자인 토큰은 디자인 결정을 변수로 저장한 것이다. 구조와 시각 스타일을 분리해 두기 때문에, 테마를 바꾸거나 브랜드 색을 고치면 파일을 하나하나 건드리지 않아도 모든 컴포넌트에 반영된다.
Senco는 ds.css에 토큰 306개를 여섯 계층으로 나눠 정의한다. 다크 테마와 프로토타입 범위에서 이를 다시 정의하면 306개 이름이 --ds-* 선언 497개가 되고, 프레임워크 브리지 변수 41개를 더하면 선언은 모두 537개다. 숫자가 셋인 건 묻는 질문이 셋이기 때문이고, tokens.json은 셋을 모두 보여 준다. 모든 계층은 바로 아래 계층을 참조하고, 아래 계층을 건너뛰지 않는다. 이 규칙 하나 덕분에 브랜드 변경, 테마 전환, 플랫폼 교체가 찾아 바꾸기 작업이 아니라 파일 하나 수정으로 끝난다.
토큰 계층
| # | Layer | Contains | Example |
|---|---|---|---|
| 1 | Base | 원시 값. 의미가 없다. | --ds-accent-600: #0F62FE |
| 2 | Semantic | 의도를 담은 별칭. 이 값이 어디에 쓰이는지를 나타낸다. | --ds-primary: var(--ds-accent-600) |
| 3 | State | 상호작용에 따라 시맨틱 토큰을 변환한 값. | --ds-primary-hover: var(--ds-accent-700) |
| 4 | Component | 컴포넌트 하나에만 쓴다. 시맨틱과 상태 토큰을 조합한다. | --ds-actionbtn-primary-bg: var(--ds-primary) |
| 5 | Platform | 테마, 모션 설정, 뷰포트에 따라 값을 정한다. | .ds-layout.ds-dark { --ds-primary: #458DFF } |
| 6 | Bridge | DS 토큰을 실제로 쓰는 프레임워크에 연결한다. | --primary: var(--ds-primary) |
전체 의존 관계:
Figma variable → Base → Semantic → State → Component → Platform → Tailwind/shadcn
계층을 여섯으로 나눈 이유
처음에는 Base → Semantic → Component → Tailwind, 네 계층이었다. 문제 세 가지가 생기기 전까지는 잘 돌아갔다.
| Problem | Symptom | Layer added |
|---|---|---|
| Hover와 Focus 값이 컴포넌트 토큰마다 하드코딩돼 있었다 | 포커스 링 하나를 바꾸려면 컴포넌트 블록 14개를 고쳐야 했다 | State |
| 다크 모드를 컴포넌트마다 따로 덧붙였다 | 새 컴포넌트가 라이트 모드만 지원한 채 출시됐고, QA 전까지 아무도 몰랐다 | Platform |
| 프레임워크 변수가 시맨틱 계층에 섞여 있었다 | Tailwind를 모르면 DS를 읽을 수 없었다 | Bridge (정식 계층으로) |
State와 Platform 계층은 새 값을 추가하지 않는다. 대신 값이 어떻게 바뀌는지에 대한 규칙을 추가한다. 예전에 손으로 여러 번 반복해 쓰던 게 바로 이 부분이다.
계층 규칙
- 토큰은 바로 아래 계층이나 그보다 더 아래 계층만 참조할 수 있다. 위 계층은 참조할 수 없다.
- 컴포넌트는 Base 토큰을 참조하지 않는다. 컴포넌트에 원시 값이 필요하다면 예외를 둘 게 아니라 빠진 시맨틱 토큰을 만들어야 한다.
- State 계층은 새 팔레트가 아니라 변환을 정의한다. Hover 값을 시맨틱 부모에서 끌어낼 수 없다면, 시맨틱 토큰이 잘못된 것이다.
- Platform 계층은 기존 토큰을 다시 매핑만 한다. 다른 곳에 없는 토큰을 새로 정의하지 않는다.
- "Tailwind"나 "shadcn"이라는 이름을 알아도 되는 건 Bridge 계층뿐이다. 그 아래 계층은 모두 프레임워크와 무관하다.
이름 규칙
--ds-{group}-{role}-{variant}-{state}
│ │ │ └── hover, active, selected, disabled
│ │ └── sm / md / lg / primary / secondary
│ └── bg, text, border, icon, shadow, radius
└── palette group, semantic role, or component name
모든 토큰 이름은 --ds-로 시작한다. 같은 범위에서 Tailwind 자체 변수와 함께 쓰여도 이름이 겹치지 않는 건 이 접두어 덕분이다.
Figma variable → Base 토큰
Figma variable 컬렉션은 Base 계층과 1:1로 대응한다. MCP 서버가 컬렉션 경로가 그대로 담긴 variable 이름을 넘겨주므로, 기계적으로 바꾸면 된다.
| Figma variable | Base token |
|---|---|
Color/Accent/600 | --ds-accent-600 |
Spacing/4 | --ds-space-4 |
Radius/lg | --ds-radius-lg |
Type/Size/base | --ds-text-base |
Semantic 계층부터 위는 Figma가 아니라 코드에서 작성한다. 원시 값은 Figma가, 의미는 DS가 관리한다. 그래서 Figma 스타일 이름이 바뀌어도 컴포넌트는 깨지지 않는다. 영향을 받는 건 언제나 Base 토큰 하나뿐이다.
Base 토큰
의미가 없는 원시 값이다. 상위 계층에서 참조하는 용도로만 존재한다.
색
팔레트 그룹은 일곱 개이고, 그룹마다 900(가장 어두움)부터 50(가장 밝음)까지 10단계가 있다.
| Group | Range | Purpose |
|---|---|---|
--ds-primary-* | #111827 → #F8FAFC | 어두운 기준색. 제목, 텍스트, 골격 |
--ds-accent-* | #001D6C → #F5F8FF | 브랜드 파랑. 상호작용 요소 |
--ds-info-* | #1E3A5F → #F0F7FF | 차분한 파랑. 안내용이며 브랜드 색과 구분한다 |
--ds-gray-* | #111827 → #F9FAFB | 텍스트 위계, 테두리, 표면 |
--ds-green-* | #14532D → #F0FAF3 | 성공 |
--ds-yellow-* | #713F12 → #FDF8EA | 경고 |
--ds-red-* | #7F1D1D → #FDF3F3 | 에러, 파괴적 액션 |
선언된 70단계 전체다. 한 단계 어두운 Hover, 400 단계 테두리, 700 단계 아이콘처럼 시스템 곳곳에서 끌어 쓰는 값은 ds.css를 열지 않아도 이 표에서 모두 찾을 수 있다.
| Group | 900 | 800 | 700 | 600 | 500 | 400 | 300 | 200 | 100 | 50 |
|---|---|---|---|---|---|---|---|---|---|---|
primary | #111827 | #1E293B | #334155 | #475569 | #64748B | #94A3B8 | #CBD5E1 | #E2E8F0 | #F1F5F9 | #F8FAFC |
accent | #001D6C | #002D9C | #0043CE | #0F62FE | #4589FF | #78A9FF | #A6C8FF | #D0E2FF | #E8F0FE | #F5F8FF |
info | #1E3A5F | #1E4D8C | #2563A0 | #3B82B8 | #60A5D4 | #93C5E8 | #BAD9F1 | #DBEAFE | #EFF6FF | #F0F7FF |
gray | #111827 | #1F2937 | #374151 | #4B5563 | #636971 | #727780 | #D1D5DB | #E5E7EB | #F3F4F6 | #F9FAFB |
green | #14532D | #166534 | #15803D | #22915A | #4AAD6E | #6EC98E | #A3DFBA | #C6EDCF | #E2F5E7 | #F0FAF3 |
yellow | #713F12 | #854D0E | #A16207 | #C07C14 | #D4952C | #E5B34A | #F0CE70 | #F5DEA0 | #FAF0D2 | #FDF8EA |
red | #7F1D1D | #922525 | #A93434 | #BB4A4A | #D46060 | #E08888 | #EBADAD | #F5D0D0 | #FAE8E8 | #FDF3F3 |
여기에 --ds-white(#FFFFFF)와 --ds-hero-gradient(linear-gradient(180deg, #0A4ABF, #0F62FE))가 추가된다. 후자는 시스템에서 유일한 그라디언트이고, 마케팅 화면에만 쓴다.
회색 두 단계는 일부러 Tailwind 기본값과 다르게 잡았다. gray-500(#636971, 보통은 #6B7280)과 gray-400(#727780, 보통은 #9CA3AF)이다. 흐린 텍스트와 placeholder가 흰 배경에서 4.5:1을 넘을 때까지 둘 다 어둡게 했다. 전체 대비 표는 Color 참고.
간격
4px 기본 단위에 10단계다. 모든 단계가 4의 배수이고, 중간 단계나 임의의 값은 없다.
| Token | rem | px | Typical use |
|---|---|---|---|
--ds-space-1 | 0.25rem | 4px | 배지 점과 라벨 사이 |
--ds-space-2 | 0.5rem | 8px | 나란히 놓인 요소 간격, 아이콘 여백 |
--ds-space-3 | 0.75rem | 12px | 거래 행 내부 패딩 |
--ds-space-4 | 1rem | 16px | 폼 필드 간격, 모바일 화면 가장자리 |
--ds-space-5 | 1.5rem | 24px | 카드 패딩, 콘텐츠 패딩 |
--ds-space-6 | 2rem | 32px | 섹션 간격, 데스크톱 여백 |
--ds-space-7 | 2.5rem | 40px | 큰 섹션 구분 |
--ds-space-8 | 3rem | 48px | 큰 섹션 사이를 크게 끊을 때 |
--ds-space-9 | 4rem | 64px | 페이지 단위 간격 |
--ds-space-10 | 6rem | 96px | 히어로 간격 |
타이포그래피
Minor third 스케일(1.200)을 4px 그리드에 맞췄다.
| Token | rem | px | Role |
|---|---|---|---|
--ds-text-2xs | 0.625rem | 10px | 빽빽한 메타데이터 |
--ds-text-xs | 0.75rem | 12px | 캡션, 도움말 텍스트 |
--ds-text-sm | 0.875rem | 14px | 라벨, 보조 본문 |
--ds-text-base | 1rem | 16px | 본문 |
--ds-text-lg | 1.125rem | 18px | 리드 문단 |
--ds-text-xl | 1.375rem | 22px | H3 |
--ds-text-2xl | 1.75rem | 28px | H2 |
--ds-text-3xl | 2.25rem | 36px | H1 |
--ds-text-display | 2.5rem | 40px | 잔액 숫자, 히어로 숫자 |
--ds-text-page-title | clamp(1.75rem, 1.25rem + 1.5vw, 2.063rem) | 28→33px | 화면 폭에 따라 변하는 페이지 제목 |
패밀리와 굵기:
| Token | Value |
|---|---|
--ds-font-sans | system-ui, -apple-system, "Segoe UI", sans-serif |
--ds-font-mono | ui-monospace, "SF Mono", "Cascadia Code", monospace |
--ds-font-regular | 400 |
--ds-font-medium | 500 |
--ds-font-semibold | 600 |
--ds-font-bold | 700 |
--ds-tracking-tight | -0.02em. Display와 H1에만 쓴다 |
시스템 폰트는 임시방편이 아니라 일부러 택한 제약이다. Senco 사용자는 데이터 요금이 나가고 불안정한 연결을 쓴다. 웹폰트는 렌더링을 막는 요청이면서 시스템에 필요한 건 아무것도 주지 않는다. 커스텀 서체가 Figma MCP 파이프라인을 거치면 사라지는 문제도 함께 피할 수 있다.
모양과 높이
| Token | Value | Applied to |
|---|---|---|
--ds-radius-none | 0 | 화면 끝까지 채우는 섹션 |
--ds-radius-sm | 0.25rem (4px) | 작은 칩 |
--ds-radius-md | 0.375rem (6px) | 버튼, 입력 |
--ds-radius-lg | 0.5rem (8px) | 카드, 다이얼로그 |
--ds-radius-xl | 0.75rem (12px) | 모달, 시트, 폼 카드 |
--ds-radius-2xl | 1rem (16px) | 큰 카드, 바텀 시트 |
--ds-radius-full | 9999px | 알약형, 아바타 |
--ds-border-width | 1px | 기본 테두리 |
--ds-border-width-focus | 2px | 포커스 외곽선 |
| Token | Value |
|---|---|
--ds-shadow-xs | 0 1px 2px rgba(0,0,0,0.03) |
--ds-shadow-sm | 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.03) |
--ds-shadow-md | 0 4px 6px rgba(0,0,0,0.05), 0 2px 4px rgba(0,0,0,0.03) |
--ds-shadow-lg | 0 10px 15px rgba(0,0,0,0.06), 0 4px 6px rgba(0,0,0,0.03) |
--ds-shadow-xl | 0 20px 25px rgba(0,0,0,0.08), 0 8px 10px rgba(0,0,0,0.03) |
그림자 불투명도는 최대 8%다. Senco에서 높이는 화려한 효과가 아니라 층위를 보여 주는 수단이다. 모달은 다른 재질이 아니라 페이지 위에 떠 있는 것으로 보여야 한다.
모션
| Token | Value | Used for |
|---|---|---|
--ds-duration-instant | 50ms | 누를 때의 상태 피드백 |
--ds-duration-fast | 100ms | Hover, Focus |
--ds-duration-normal | 180ms | 기본 전환 |
--ds-duration-slow | 300ms | 시트, 다이얼로그 |
--ds-duration-slower | 500ms | 전체 화면 전환 |
--ds-ease-default | cubic-bezier(0.4, 0, 0.2, 1) | 따로 지정하지 않으면 모두 이걸 쓴다 |
--ds-ease-in | cubic-bezier(0.4, 0, 1, 1) | 퇴장 |
--ds-ease-out | cubic-bezier(0, 0, 0.2, 1) | 등장 |
--ds-ease-bounce | cubic-bezier(0.34, 1.56, 0.64, 1) | 완료 확인에만 |
--ds-transition | var(--ds-duration-normal) var(--ds-ease-default) | 조합 축약형 |
크기
| Token | Value | Notes |
|---|---|---|
--ds-size-touch | 48px | 최소 터치 타깃. WCAG 2.2 SC 2.5.8 |
--ds-size-icon | 20px | 기본 아이콘 |
--ds-size-icon-sm | 16px | text-sm 옆 문장 안 아이콘 |
--ds-size-icon-lg | 24px | 단독 아이콘, 내비 아이콘 |
--ds-size-avatar | 36px | 연락처 아바타 |
--ds-size-appbar | 48px | 모바일 App bar |
--ds-size-bottomnav | 52px | 하단 탭 바 |
--ds-size-list-item | 60px | 거래 행 |
--ds-input-height | 48px | 기본 컨트롤 높이 |
--ds-input-height-sm | 36px | Dense 컨트롤 높이 |
Z-index
6단계로 고정된 스케일이다. 어떤 컴포넌트도 이 밖의 z-index를 새로 만들 수 없다.
| Token | Value |
|---|---|
--ds-z-dropdown | 100 |
--ds-z-sticky | 200 |
--ds-z-overlay | 300 |
--ds-z-modal | 400 |
--ds-z-popover | 500 |
--ds-z-toast | 600 |
Toast를 모달보다 위에 둔 건 의도한 것이다. 확인 다이얼로그가 열려 있어도 송금 실패 메시지는 보여야 한다.
| Swatch | Token | CSS var | Value |
|---|---|---|---|
| bg | --ds-bg | ||
| fg | --ds-fg | ||
| heading | --ds-heading | ||
| muted | --ds-muted | ||
| border | --ds-border | ||
| surface | --ds-surface | ||
| white | --ds-white | ||
| on-accent | --ds-on-accent | ||
| primary | --ds-primary | ||
| primary-hover | --ds-primary-hover | ||
| primary-light | --ds-primary-light | ||
| primary-900 | --ds-primary-900 | ||
| link | --ds-link | ||
| focus | --ds-focus | ||
| overlay | --ds-overlay | ||
| disabled-bg | --ds-disabled-bg | ||
| disabled-text | --ds-disabled-text | ||
| success-bg | --ds-success-bg | ||
| success-border | --ds-success-border | ||
| success-text | --ds-success-text | ||
| warning-bg | --ds-warning-bg | ||
| warning-border | --ds-warning-border | ||
| warning-text | --ds-warning-text | ||
| error-bg | --ds-error-bg | ||
| error-border | --ds-error-border | ||
| error-text | --ds-error-text | ||
| info-bg | --ds-info-bg | ||
| info-border | --ds-info-border | ||
| info-text | --ds-info-text |
| Token | CSS var | Value | Visual |
|---|---|---|---|
| space-1 | --ds-space-1 | ||
| space-2 | --ds-space-2 | ||
| space-3 | --ds-space-3 | ||
| space-4 | --ds-space-4 | ||
| space-5 | --ds-space-5 | ||
| space-6 | --ds-space-6 | ||
| space-7 | --ds-space-7 | ||
| space-8 | --ds-space-8 | ||
| space-9 | --ds-space-9 | ||
| space-10 | --ds-space-10 |
| Token | CSS var | Resolved value |
|---|---|---|
| font-sans | --ds-font-sans | |
| font-mono | --ds-font-mono |
Font sizes
| Token | CSS var | Value | Sample |
|---|---|---|---|
| radius-none | --ds-radius-none | ||
| radius-sm | --ds-radius-sm | ||
| radius-md | --ds-radius-md | ||
| radius-lg | --ds-radius-lg | ||
| radius-xl | --ds-radius-xl |
Semantic 토큰
의도를 담은 별칭이다. 컴포넌트는 Base 계층이 아니라 이 계층을 참조한다. 이렇게 한 단계를 거치기 때문에 테마를 바꿀 수 있다.
색
| Token | Light value | Purpose |
|---|---|---|
--ds-bg | {white} | 페이지 배경 |
--ds-surface | {gray-100} | 카드·섹션 채움 |
--ds-fg | {gray-700} | 본문 텍스트 |
--ds-heading | {primary-900} | 제목, 강한 텍스트 |
--ds-muted | {gray-500} | 보조 텍스트, 힌트, 라벨 |
--ds-border | {gray-200} | 구분선, 입력 테두리 |
--ds-primary | {accent-600} | 브랜드, 주요 액션 |
--ds-primary-light | {accent-50} | 옅은 색을 깐 배경 |
--ds-link | {accent-600} | 문장 안 링크 |
--ds-focus | {accent-600} | 포커스 링의 기준 색 |
--ds-placeholder | {gray-400} | 필드의 placeholder 텍스트 |
--ds-overlay | color-mix(in srgb, {gray-900} 10%, transparent) | 모달과 시트의 배경막 |
On 색
채워진 표면 위에 올리는 텍스트와 아이콘 색이다. 흰 글자가 읽힐지 말지를 컴포넌트마다 짐작하지 않게 하려고 둔다.
| Token | Value | Used on |
|---|---|---|
--ds-on-accent | 라이트 #FFFFFF, 다크 #0F172A | --ds-primary 채움 위의 라벨과 표시 |
--ds-on-surface | {gray-900} | 밝은 표면 |
--ds-on-error | #FFFFFF | red-600으로 채운 파괴적 액션 |
피드백
피드백 상태마다 배경, 테두리, 텍스트, 아이콘 토큰 네 개가 한 세트라서, 알림마다 색을 따로 조합할 필요가 없다.
| State | Background | Border | Text | Icon |
|---|---|---|---|---|
| Info | --ds-info-bg | --ds-info-border | --ds-info-text | --ds-info-icon |
| Success | --ds-success-bg | --ds-success-border | --ds-success-text | --ds-success-icon |
| Warning | --ds-warning-bg | --ds-warning-border | --ds-warning-text | --ds-warning-icon |
| Error | --ds-error-bg | --ds-error-border | --ds-error-text | --ds-error-icon |
| Tip | --ds-tip-bg | --ds-tip-border | --ds-tip-text | — |
Tip은 info가 아니라 accent를 기반으로 한다. Info는 시스템이 보내는 메시지이고, tip은 Senco가 건네는 말이다. 색조를 다르게 두어야 복잡한 송금 화면에서 둘이 섞여 보이지 않는다.
간격과 타이포그래피 별칭
시맨틱 간격은 크기가 아니라 관계에 이름을 붙인다. 이 이름들은 변수로 선언된 게 아니라 관례로 쓰고 있는 것이다. 일관성을 지키려고 관계를 문서화해 두었고, 토큰으로 정식화하는 건 남은 작업이다.
| Convention | Resolves to | Meaning |
|---|---|---|
| 인라인 간격 | --ds-space-2 | 아이콘과 라벨 사이 |
| 필드 간격 | --ds-space-4 | 폼 필드 사이 |
| 섹션 간격 | --ds-space-6 | 페이지 섹션 사이 |
| 카드 패딩 | --ds-space-5 | 카드 안 |
State 토큰
State 계층은 시맨틱 토큰이 상호작용에 따라 어떻게 바뀌는지 정의한다. 따로 팔레트를 두지 않는다. 모든 상태 값은 문서화된 규칙에 따라 시맨틱 부모에서 끌어낸다.
변환 규칙
| State | Rule | Example |
|---|---|---|
| Hover | 기본 색 단계에서 한 단계 어둡게 (600 → 700) | --ds-primary → --ds-primary-hover |
| Hover (surface) | 그림자를 한 단계 올린다 | --ds-shadow-xs → --ds-shadow-sm |
| Focus, bordered control | 테두리를 --ds-focus로 바꾸고, 불투명도 20%의 3px 후광을 오프셋 없이 더한다 | box-shadow로 쓰는 --ds-focus-ring |
| Focus, borderless control | 2px 오프셋을 두고 3px 실선 --ds-focus 외곽선 | outline: var(--ds-focus-ring-width) solid var(--ds-focus) + outline-offset: var(--ds-focus-ring-offset) |
| Active | Hover와 같은 값, 높이 변화 없음 | 누른 상태는 "떠오른" 게 아니라 "눌린" 것으로 보인다 |
| Selected | 테두리는 --ds-primary, 배경은 --ds-primary-light | 라디오 카드 선택 |
| Disabled | 배경 {gray-300}, 텍스트 {gray-500} | --ds-disabled-bg / --ds-disabled-text |
어느 포커스 형태를 쓸지는 질문 하나로 정한다. 평소에 테두리가 보이는 컨트롤인가? 입력, 셀렉트, 입력 그룹, 테두리 있는 카드는 테두리 형태를 쓴다. 버튼, 링크, 토글 항목, 탭, 스위치, 목록 행, 스크롤 영역은 외곽선을 쓴다. 3:1을 넘어야 하는 건 테두리나 외곽선이다. 20% 후광은 장식이고, 흰 배경 대비 1.33:1 정도다.
규칙마다 한 번만 정의하기 때문에, 버튼, 연락처 카드, 사이드바 항목의 Hover 동작은 비슷해 보이는 구현 세 개가 아니라 확실히 같은 동작이 된다.
구현된 State 토큰
| Token | Value |
|---|---|
--ds-primary-hover | var(--ds-accent-700) |
--ds-disabled-bg | var(--ds-gray-300) |
--ds-disabled-text | var(--ds-gray-500) |
--ds-focus-ring-color | var(--ds-focus) |
--ds-focus-ring-width | 3px |
--ds-focus-ring-opacity | 20% |
--ds-focus-ring-offset | 2px. 외곽선 형태에만 쓴다. box-shadow에는 오프셋이 없다 |
--ds-focus-ring | 0 0 0 var(--ds-focus-ring-width) color-mix(in srgb, var(--ds-focus-ring-color) var(--ds-focus-ring-opacity), transparent) |
--ds-focus-ring-error | 같은 공식에 --ds-red-600을 쓴다 |
포커스 링은 고정 rgba 값 대신 color-mix()로 만든다. 그래서 다크 모드에서 --ds-focus를 다시 매핑하면 링도 따로 손대지 않고 맞는 색이 된다. 변환 자체가 토큰이라서 테마가 바뀌어도 변환은 그대로 유지된다.
컴포넌트 토큰의 상태 짝
상태가 있는 컴포넌트 토큰은 상태를 이름에 명시하고, 짝을 맞춰 정의한다.
--ds-actionbtn-primary-bg: var(--ds-primary);
--ds-actionbtn-primary-bg-hover: var(--ds-primary-hover);
--ds-actionbtn-primary-shadow: var(--ds-shadow-sm);
--ds-actionbtn-primary-shadow-hover: var(--ds-shadow-md);
-hover 변형만 있고 기본 변형이 없는 컴포넌트 토큰은 버그다. 리뷰에서 짝이 없는 상태로 걸러 낸다.
유틸리티 접두어
마크업에서는 Tailwind 접두어 문법으로 상태를 적용하고, Bridge 계층을 거쳐 값이 정해진다.
| State | CSS prefix | Example |
|---|---|---|
| Hover | hover: | hover:bg-primary-hover |
| Focus | focus-visible: | focus-visible:ring-focus |
| Active | active: | active:bg-primary-hover |
| Disabled | aria-disabled: | aria-disabled:bg-disabled. Senco 컨트롤은 aria-disabled를 쓰므로 disabled:는 적용되지 않는다 |
포커스에는 focus:가 아니라 focus-visible:을 쓴다. 버튼을 마우스로 클릭했을 때는 링이 남지 않아야 하고, Tab을 눌렀을 때는 남아야 한다.
Component 토큰
컴포넌트 하나에만 쓰는 토큰이다. 시맨틱 토큰과 상태 토큰을 조합하며, 특정 컴포넌트만의 결정은 여기에만 둘 수 있다.
버튼: 컴포넌트 셋, 토큰 세트 셋
--ds-actionbtn-*은 일반 버튼용이 아니다. 대시보드의 액션 타일용이다. 라벨 위에 40px 원형 아이콘이 있는, 높이 72px짜리 카드다. 이걸 일반 버튼 토큰으로 오해하는 바람에 파일 세 곳에서 radius가 6px이냐 9999px이냐로 서로 어긋났다. 그래서 어느 컴포넌트 토큰인지 여기에 분명히 적어 둔다.
| Component | Radius | Height | Token set |
|---|---|---|---|
| Button (default) | --ds-radius-md (6px) | --ds-size-touch (48px) | 컴포넌트 블록 없음. 시맨틱과 상태 토큰을 조합한다 |
| Pill button | --ds-radius-full | --ds-pillbtn-h (48px) | --ds-pillbtn-* |
| Action tile | --ds-actionbtn-radius (full) | --ds-actionbtn-min-h (72px) | --ds-actionbtn-* |
Action tile
| Token | Value |
|---|---|
--ds-actionbtn-primary-bg | var(--ds-primary) |
--ds-actionbtn-primary-bg-hover | var(--ds-primary-hover) |
--ds-actionbtn-primary-text | var(--ds-on-accent) |
--ds-actionbtn-primary-icon-bg | rgba(255,255,255,0.15) |
--ds-actionbtn-primary-shadow | var(--ds-shadow-sm) |
--ds-actionbtn-primary-shadow-hover | var(--ds-shadow-md) |
--ds-actionbtn-secondary-bg | var(--ds-bg) |
--ds-actionbtn-secondary-bg-hover | var(--ds-surface) |
--ds-actionbtn-secondary-text | var(--ds-heading) |
--ds-actionbtn-secondary-border | var(--ds-border) |
--ds-actionbtn-secondary-border-hover | var(--ds-accent-200) |
--ds-actionbtn-secondary-icon-bg | var(--ds-accent-50) |
--ds-actionbtn-secondary-icon-color | var(--ds-primary) |
--ds-actionbtn-secondary-shadow | var(--ds-shadow-xs) |
--ds-actionbtn-secondary-shadow-hover | var(--ds-shadow-sm) |
--ds-actionbtn-radius | var(--ds-radius-full) |
--ds-actionbtn-icon-size | 2.5rem |
--ds-actionbtn-icon-radius | var(--ds-radius-full) |
--ds-actionbtn-min-h | 4.5rem |
--ds-actionbtn-label-size | var(--ds-text-sm) |
--ds-actionbtn-label-weight | var(--ds-font-semibold) |
Pill button
| Token | Value |
|---|---|
--ds-pillbtn-h | 3rem |
--ds-pillbtn-px | 1.5rem |
--ds-pillbtn-font-size | var(--ds-text-sm) |
--ds-pillbtn-font-weight | var(--ds-font-semibold) |
--ds-pillbtn-primary-bg | var(--ds-primary) |
--ds-pillbtn-primary-text | var(--ds-on-accent) |
--ds-pillbtn-secondary-bg | var(--ds-bg) |
--ds-pillbtn-secondary-text | var(--ds-primary) |
--ds-pillbtn-secondary-border | var(--ds-primary) |
**Button (default)**에는 컴포넌트 블록이 없다. --ds-primary / --ds-primary-hover / --ds-on-accent / --ds-border를 --ds-radius-md와 직접 조합한다. Danger variant에도 컴포넌트 토큰이 없어서, 지금은 --ds-red-600을 바로 참조한다. 이 계층 규칙에서 금지하는 Base 참조다. 아래 알려진 drift에 적어 두었다.
Input
| Token | Value |
|---|---|
--ds-input-height | 48px |
--ds-radiocard-border | var(--ds-border) |
--ds-radiocard-border-hover | var(--ds-gray-400) |
--ds-radiocard-border-selected | var(--ds-primary) |
--ds-radiocard-bg-selected | var(--ds-primary-light) |
--ds-fileupload-dropzone-border | var(--ds-gray-300) |
--ds-fileupload-dropzone-border-active | var(--ds-primary) |
--ds-multiselect-chip-bg | var(--ds-gray-100) |
--ds-multiselect-chip-remove-hover | var(--ds-red-600) |
Notice / Feedback
피드백 컴포넌트는 시맨틱 피드백 세트를 그대로 쓰고, 자체 컴포넌트 토큰을 두지 않는다. 일부러 그렇게 했다. 알림에 별도 색이 필요하다면 시맨틱 세트가 덜 갖춰졌다는 뜻이다.
Senco 컴포넌트
제품 고유 컴포넌트의 컴포넌트 토큰 블록이 가장 크다. 일반 라이브러리에는 없을 도메인 결정이 담겨 있기 때문이다.
| Component | Token group | Notable decision |
|---|---|---|
| Status badge | .snc-badge-* | 상태 여섯 가지. 배경, 텍스트, 점 색을 서로 다른 색 단계 세 곳에서 가져오는 유일한 곳 |
| Status timeline | --ds-timeline-* | 완료된 단계는 초록, 진행 중인 단계는 브랜드 파랑, 남은 단계는 gray-300 |
| Bottom tab bar | --ds-bottomtab-* | 배지는 흰 바탕에 red-600. 햇빛 아래에서도 한눈에 보인다 |
| Camera capture | --ds-camera-* | 테마 토큰이 아니라 #111827로 고정한 크롬. 뷰파인더는 두 테마 모두 어둡다 |
| QR scanner | --ds-qr-* | 같은 고정 다크 규칙. 스캔 선은 검은 바탕에서 잘 보이도록 accent-400을 쓴다 |
| Receipt view | --ds-receipt-* | 찢어낸 가장자리가 있는 종이 모양. 헤더와 푸터는 gray-50 |
| Contact card | --ds-contactcard-* | Hover 때 그림자만 올리고, 테두리 색은 바꾸지 않는다 |
카메라와 QR 표면은 테마 재매핑에서 유일하게 문서화한 예외다. 라이트 모드에서 뷰파인더가 뒤집히면 얻는 건 없고 사용자가 익숙한 기준만 잃는다. 그래서 이 토큰들은 일부러 고정 값으로 둔다. CSS를 뒤지다 발견하게 두지 않고 여기에 적어 둔다.
Platform 토큰
Platform 계층은 실행 환경에 맞춰 값을 정한다. 새 값을 만들지 않고 이미 있는 토큰만 다시 매핑하므로, 테마를 추가해도 컴포넌트가 깨질 일이 없다.
테마에 따른 값
다크 모드는 .ds-layout.ds-dark에서 시맨틱 계층을 다시 매핑해 적용한다.
| Token | Light | Dark |
|---|---|---|
--ds-bg | #FFFFFF | #0F172A |
--ds-surface | {gray-100} | #1E293B |
--ds-fg | {gray-700} | #CBD5E1 |
--ds-heading | {primary-900} | #F1F5F9 |
--ds-muted | {gray-500} | #94A3B8 |
--ds-border | {gray-200} | #334155 |
--ds-primary | #0F62FE | #458DFF |
--ds-primary-hover | #0043CE | #3079FF |
--ds-primary-light | {accent-50} | #0F2847 |
--ds-on-accent | #FFFFFF | #0F172A |
--ds-placeholder | {gray-400} | #8391A6 |
--ds-overlay | {gray-900} 10% | {gray-900} 30% |
다크 모드에서는 브랜드 파랑이 밝아진다. #0F172A 위의 #0F62FE는 3.57:1로, 텍스트 기준인 4.5:1에 못 미친다. #458DFF는 5.53:1로 통과한다. 기본 팔레트는 그대로 두고 시맨틱 별칭만 바꾼다.
Primary 채움을 밝게 하면서 생긴 문제도 같은 계층에서 처리한다. #458DFF 위의 흰 라벨은 3.23:1이라서, --ds-on-accent를 #0F172A로 다시 매핑한다. 버튼 컴포넌트는 바뀌지 않고 그대로 --ds-on-accent를 읽는다. Themes 참고.
동작 줄이기
Platform 블록 하나로 시스템 전체의 애니메이션을 끈다. 컴포넌트마다 따로 처리하지 않는다.
@media (prefers-reduced-motion: reduce) {
.ds-layout *,
.ds-layout *::before,
.ds-layout *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
뷰포트에 따른 값
브레이크포인트는 컴포넌트가 정하는 게 아니라 Platform 계층의 입력값이다. 컴포넌트는 컨테이너 토큰을 읽기만 하고, 어떤 컨테이너를 적용할지는 Platform 계층이 정한다. 아래 반응형 브레이크포인트 토큰 참고.
범위별 플랫폼 대상
| Scope | Purpose |
|---|---|
.ds-layout | 문서 사이트. DS 토큰 전체 |
.ds-layout.ds-dark | 다크 테마 재매핑 |
.senco-prototype-scope | 기기 프레임 프로토타입. 라이트 값을 고정해서, 포트폴리오의 다크 테마가 모바일 목업까지 번지지 않게 한다 |
프로토타입 범위는 임시 우회책이 아니라 정식 플랫폼 대상이다. 프로토타입 프레임은 Senco 앱을 나타내고, 앱은 자신을 담은 페이지와 별개로 자체 테마 상태를 갖는다.
밀도: 정의만 하고 구현하지 않음
이 계층에는 --ds-space-*와 컨트롤 높이를 다시 매핑하는 compact, cozy 밀도 변형 자리를 마련해 두었다. 지금 ds.css에는 둘 다 구현돼 있지 않다. Senco 화면은 한 번에 한 가지 일만 하는 모바일 우선 화면이라, 밀도 변형을 만들 만큼 필요했던 적이 없다. 나중에 데스크톱 거래 테이블에서 필요해지면 컴포넌트별 덮어쓰기가 아니라 Platform 계층에 넣을 수 있도록 자리만 문서로 남겨 둔다.
컴포넌트 라이브러리 통합
여섯 번째이자 가장 바깥 계층인 Bridge다. Senco는 shadcn(base-nova 스타일)을 기반으로 한다. CSS 변수 브리지가 .ds-layout 범위 안에서 shadcn과 Tailwind 변수를 Senco 시맨틱 토큰에 연결한다.
| shadcn variable | Maps to |
|---|---|
--background | --ds-bg |
--foreground | --ds-fg |
--card | --ds-white |
--card-foreground | --ds-heading |
--popover | --ds-white |
--primary | --ds-primary |
--primary-foreground | --ds-on-accent |
--secondary | --ds-white |
--secondary-foreground | --ds-primary |
--muted | --ds-surface |
--muted-foreground | --ds-muted |
--accent | --ds-surface |
--destructive | --ds-red-600 |
--border | --ds-border |
--input | --ds-border |
--ring | --ds-primary |
--radius | 0.5rem |
사이드바와 차트 브리지도 같은 방식이다(--sidebar-*, --chart-1 … --chart-5).
이 브리지는 ds.css에 있어서, shadcn 컴포넌트를 하나하나 덮어쓰지 않아도 Senco 브랜딩이 적용된다.
브리지는 상속에 의존한다
브리지는 범위 루트에 한 번만 선언하고, 모든 컴포넌트가 상속으로 값을 읽는다. 그런데 호스트 애플리케이션의 테마가 .theme이 아니라 .theme *로 작성돼 있는 식으로, 자손 요소에서 같은 프레임워크 변수를 다시 선언하면 Senco 값이 호스트 값으로 소리 없이 바뀐다. 색 말고는 깨진 데가 없어 보여서 알아채기 어렵다. 이런 규칙이 있는 호스트에 시스템을 넣을 때는, 브리지가 정의하는 변수에 한해 범위에서 상속을 다시 지정한다.
.ds-scope.host-theme *:not(.prototype-scope) {
--primary: inherit;
--primary-foreground: inherit;
/* …every variable the bridge declares */
}
확인은 한 번이면 된다. 범위 안의 Primary 버튼이 라이트 모드에서 --ds-primary 색으로, 테마를 바꾼 뒤에는 다크 값으로 보여야 한다. 두 테마에서 색이 같다면 브리지가 거기까지 닿지 않는 것이다.
브리지를 세부 구현이 아니라 계층으로 둔 이유
6계층 아래는 모두 특정 기술과 무관하다. Senco를 Tailwind에서 CSS-in-JS나 네이티브 플랫폼으로 옮기려면 브리지 파일 하나만 다시 쓰면 된다. DS는 무엇이 자신을 렌더링하는지 모르고, 이 계층은 그 성질을 지키려고 존재한다.
타이밍과 임계값
애니메이션이 아닌 시간 값이다. 스펙에 "debounce한다", "throttle한다", "타임아웃이 있다"고만 쓰면, 요구사항은 적었지만 개발자가 스스로 알아낼 수 없는 단 하나, 숫자를 빼먹은 셈이다. 개발자 둘이 서로 다른 숫자를 고르면, 환율 견적의 경우 화면에 가격이 두 가지로 뜬다.
아래는 시스템 기본값이다. 제품의 특정 화면에서 값을 바꿀 수는 있지만, 값을 적지 않고 넘어갈 수는 없다.
| Constant | Value | Applies to | Why this number |
|---|---|---|---|
| 입력 안정화 | 300ms | 통화 환산 재계산, 검색 필터링 | 200ms 정도보다 짧으면 단어를 치는 중간에 실행되고, 500ms 정도보다 길면 결과가 타이핑과 따로 노는 느낌이 든다. 300ms는 잠깐 멈춘 것이 "입력을 마쳤다"로 느껴지는 간격이다 |
| 안내 throttle, 일상 갱신 | 30s | 환율 갱신 안내 | 대시보드 환율 폴링 주기와 맞춰서, 안내와 화면 갱신이 따로가 아니라 한 번에 일어나게 한다 |
| 안내 throttle, 진행률 | 10% 또는 1s 중 덜 자주 오는 쪽 | 업로드와 인증 진행률 | 1%마다 안내하면 쓸 수가 없고, 끝날 때만 안내하면 정보가 없다 |
| 안내 debounce, 검증 | 500ms | 비밀번호 강도, 비동기 필드 검사 | 빠르게 입력하는 사람이 글자마다 방해받지 않을 만큼 길다 |
| 견적 유효 시간, 법정화폐 | 60s | 견적부터 확인까지 고정해 두는 환율 | 수수료 내역을 서두르지 않고 읽을 만큼 길고, 환율이 실제와 크게 벌어지지 않을 만큼 짧다 |
| 견적 유효 시간, 크립토 | 15s | 스테이블코인 환산 환율 | 크립토 시세는 법정화폐 견적 시간 안에도 움직인다. 60초 동안 고정하면 거래 데스크가 지킬 수 없는 가격을 제시하게 된다 |
| 요청 타임아웃, 기본 | 10s | 사용자가 스피너를 보며 기다리는 모든 요청 | 느린 연결에서 10초가 지나면 사용자는 이미 앱이 고장 났다고 생각한다. 더 기다리게 하는 것보다 실패 상태를 보여 주는 편이 낫다 |
| 요청 타임아웃, 업로드 | 30s | KYC 서류 업로드 | 사용자의 인내심이 아니라 데이터 크기에 맞췄다. 제품에서 가장 오래 걸려도 되는 작업이다 |
| 제출 버튼 다시 활성화 | 호출자가 loading을 다시 false로 바꿀 때(응답을 받거나 타임아웃이 났을 때) | 제출하는 모든 컨트롤 | 요청을 보낸 호출자가 해제도 책임진다. 호출자는 누름 핸들러에서 어떤 await보다도 먼저 loading을 동기적으로 true로 바꾼다. 그래야 같은 프레임에서 두 번 탭해도 한 번만 실행된다 |
환율 만료는 미리 보여 준다
견적은 0초가 되는 순간 말없이 만료되지 않는다. 유효 시간이 절반 남았을 때부터 남은 시간을 보여 주고, 만료되면 오래된 숫자 대신 새로 고침 액션을 보여 준다. Amount input과 Transfer flow 참고.
숫자 표시 정밀도
| Value | Precision | Example |
|---|---|---|
| 법정화폐 금액 | 통화의 최소 단위 | 250.00 USD, 412,500 NGN, 3,200 JPY |
| 크립토 금액 | 소수점 8자리, 끝자리 0은 뺀다 | 0.0042 BTC |
| 환율 | 정확히 유효숫자 6자리. minimumSignificantDigits: 6, maximumSignificantDigits: 6 | 1 USD = 1,650.00 NGN, 1 USD = 0.923145 EUR |
| 수수료 | 통화의 최소 단위. 올림하지 않는다 | 3.50 USD |
예전에는 환율 행에 "전체 정밀도"라고만 적혀 있었다. 유효숫자를 6자리로 맞춰야 송금 경로가 달라도 환율을 비교할 수 있다. 소수점 자릿수를 고정하면 0.923145가 잘리거나 1,650.00에 쓸데없는 0이 붙는다.
통화의 최소 단위
금액 정밀도는 통화의 최소 단위가 정한다. 위의 예시 세 개가 규칙의 전부는 아니다. 시스템은 Intl.NumberFormat을 통해 ISO 4217에서 최소 단위를 가져오고, 직접 처리해야 하는 예외가 두 가지 있다.
| Case | Handling |
|---|---|
| ISO 4217 통화 | Intl.NumberFormat(locale, { style: "currency", currency }).resolvedOptions() |
| 크립토 자산 | ISO 4217에 없다. 제품에서 관리하는 표를 쓴다. BTC는 소수점 8자리, USDC/USDT는 6자리 |
| 표시 반올림 | 수수료와 받는 금액은 절대 올림하지 않는다 |
알려진 공백: 아직 토큰화하지 않음
위 값은 하나도 ds.css에 --ds-*로 선언돼 있지 않다. 여기 적은 계약을 컴포넌트 로직에서 구현하고 있다. 토큰(--ds-timing-settle, --ds-timing-quote-fiat)으로 만드는 건 남은 작업이다. 그 전까지는 이 표가 유일한 기준이고, 자기 마음대로 숫자를 고른 컴포넌트는 결함이다.
반응형 브레이크포인트 토큰
| Token | Value | Applies to |
|---|---|---|
--ds-bp-mobile | 600px | 모바일 세로 화면 이하 |
--ds-bp-tablet | 1024px | 태블릿 세로부터 가로 화면까지 |
--ds-bp-desktop | 1440px | 일반 데스크톱 이상 |
브레이크포인트별로 적용되는 컨테이너 폭:
| Token | Value |
|---|---|
--ds-container-xs | 320px |
--ds-container-sm | 480px |
--ds-container-md | 640px |
--ds-container-lg | 960px |
--ds-container-xl | 1200px |
--ds-container-max | 1400px |
규칙
- 컴포넌트 스타일에 원시 값을 하드코딩하지 않는다.
- 항상 바로 아래 계층을 참조한다. 위 계층을 참조하지 않는다.
- Base 토큰은 상위 계층에서 참조하는 용도로만 존재한다.
- 컴포넌트는 Base 토큰을 참조하지 않는다. 그럴 일이 생겼다면 진짜 문제는 시맨틱 토큰이 빠져 있다는 것이다.
- State 토큰은 문서화된 변환 규칙으로 시맨틱 부모에서 끌어낼 수 있어야 한다.
-hover가 붙은 컴포넌트 토큰에는 반드시-hover가 붙지 않은 기본 짝이 있어야 한다.- Platform 덮어쓰기는 다시 매핑만 한다. 테마 블록 안에서 새로 생긴 토큰은 버그다.
- 모든 상호작용 요소는 Hover, Focus-visible, Disabled 상태를 정의한다.
- shadcn 컴포넌트는 브리지를 통해 Senco 토큰을 물려받는다. 개별로 덮어쓰지 않는다.
토큰 추가하기
- 기존 시맨틱 토큰으로 해결되는가? 그렇다면 여기서 멈춘다.
- 원시 값이고 여러 곳에서 쓸 값이라면 Base에 추가하고 Semantic에 별칭을 만든다.
- 상호작용에 따라서만 달라진다면 State에 변환 규칙으로 둔다.
- 딱 한 컴포넌트에만 해당한다면 그 컴포넌트 블록에 추가한다. 단, 1–3이 모두 해당하지 않을 때만.
- 결정을 기록한다. 이유를 적어 두지 않은 토큰은 석 달 안에 중복된다.
토큰 폐기하기
토큰 제거는 한 번에 하지 않고 반드시 두 단계로 나눈다. 먼저 ds.css에서 폐기 표시를 하고 대체할 토큰 이름을 옆에 적은 뒤, 한 릴리스 동안 별칭으로 남겨 두었다가 삭제한다. 바로 지우면 스펙 파일 29개와 모든 프로토타입을 뒤지는 일이 된다.
알려진 drift
| Item | Status |
|---|---|
--ds-focus-offset | 제거함. --ds-focus-ring-offset으로 대체 |
| 다크 테마가 Base 토큰 세 개를 덮어씀 | .ds-layout.ds-dark가 --ds-accent-50, -100, -200을 다시 정의한다. 위 규칙에서 금지하는 일이다. 이 셋은 --ds-primary-light와 사이드바 활성 행 뒤에 깔리는 옅은 색인데, 다크 모드에서는 어두운 옅은 색이 필요했고 팔레트 단계를 다시 매핑하는 게 가장 빨랐다. 제대로 고치려면 색 단계는 그대로 두고, 테마마다 다른 값을 가리키는 시맨틱 옅은 색 토큰을 만들어야 한다. 그 전까지 "브랜드 파랑은 어디서나 같은 뜻"이라는 말은 accent-600에는 맞지만 밝은 쪽 단계에는 맞지 않는다. --ds-white도 다시 매핑되지만 이건 의도한 것이다. 이름만 색 이름일 뿐 시맨틱 토큰이다 |
| 중립 피드백 세트 | --ds-gray-bg / -text / -border / -icon이 없다. Cancelled 상태는 시맨틱 계층을 건너뛰고 --ds-gray-100 / -600 / -500을 직접 참조한다. 중립 세트가 생기기 전까지 컴포넌트에서 유일하게 허용한 Base 참조다 |
--ds-tip-icon | Tip 피드백 세트에 bg, border, text는 있지만 아이콘 토큰이 없다. 지금 Tip은 아이콘 없이 출시된다 |
| Danger 버튼 토큰 | --ds-actionbtn-danger-* 같은 블록이 없다. Danger variant는 이 계층에서 금지하는 Base 참조인 --ds-red-600을 쓴다 |
| 링크 Hover / 파괴적 링크 | --ds-link에는 -hover 짝이 없어서 --ds-primary-hover를 가져다 쓴다. .link-destructive는 클래스만 있고 --ds-link-destructive 토큰은 없다 |
--ds-space-0 | 값으로는 쓰지만 변수로 선언하지 않음 |
| 줄 높이 토큰 | 비율은 Typography에서 역할별로 지정함. --ds-leading-* 토큰 없음 |
| 상태 배지 좁은 간격 | 3px / 10px 패딩과 6px 간격은 4px 스케일에 맞지 않는다. 60px 거래 행에 잘 맞아서 그대로 둔다 |
| 밀도 변형 | 위에 문서화했지만 구현 안 됨 |
| 시맨틱 간격 별칭 | 위에 문서화함. 일부는 ds.css에 선언한 변수가 아니라 관례로 쓰고 있다 |
drift는 숨기지 않고 목록으로 남긴다. 이 문서를 읽는 에이전트는 어떤 문장이 지켜야 할 계약이고 어떤 문장이 앞으로의 의도인지 알아야 한다. 문서에 없는 공백이 있으면, 틀린 코드를 자신 있게 만들어 낸다.