테마
Senco는 라이트와 다크, 두 가지 테마를 제공한다. 테마는 또 하나의 디자인 시스템이 아니라 시맨틱 계층을 다시 매핑한 것이다. 기본 팔레트, 간격 스케일, 타입 스케일, 컴포넌트 계약은 두 테마에서 모두 같다.
이 구조 덕분에 다크 모드를 유지보수할 수 있다. 시맨틱 토큰으로 만든 컴포넌트는 출시 첫날부터 다크 모드에서도 제대로 보인다. 처음부터 색 이름을 직접 쓴 적이 없기 때문이다.
테마 종류
| Theme | Scope selector | Page background | Default |
|---|---|---|---|
| Light | .ds-layout | #FFFFFF | ✓ |
| Dark | .ds-layout.ds-dark | #0F172A | — |
세 번째 범위인 .senco-prototype-scope는 기기 프레임 프로토타입 안에서 라이트 값을 고정한다. 문서 페이지가 다크여도 모바일 목업까지 다크로 바뀌지 않게 하려는 것이다. 테마가 아니라 플랫폼 대상이다. Tokens 참고.
테마 동작 방식
테마는 여섯 개 토큰 계층 중 다섯 번째인 Platform 계층에 있다. 규칙은 일부러 좁게 잡았다.
- 테마는 이미 있는 토큰만 다시 매핑할 수 있다. 테마 블록 안에서 새 토큰을 만들면 결함이다. 한 테마에만 있는 기능이 생긴다는 뜻이기 때문이다.
- 테마는 기본 계층이 아니라 시맨틱 계층을 다시 매핑한다.
--ds-accent-600은 두 테마 모두#0F62FE다. 바뀌는 건--ds-primary가 더 이상 이 값을 가리키지 않는다는 점이다. - 컴포넌트 토큰은 따로 손대지 않아도 따라온다.
--ds-actionbtn-primary-bg는var(--ds-primary)를 참조하므로 다크 모드 값을 따로 둘 필요가 없다. - 컴포넌트는 테마에 따라 분기하지 않는다. 어떤 컴포넌트에도
isDark는 없다. 컴포넌트가 테마를 알아야 한다면 토큰이 하나 빠진 것이다.
실제로 바뀌는 것
| Category | Remapped in dark? | Why |
|---|---|---|
| 시맨틱 색 | 예 | 테마를 두는 목적 자체 |
| 컴포넌트 색 토큰 | 시맨틱 재매핑만으로 부족한 곳만 | 사이드바, 코드, 테이블, 라디오 카드 |
| 간격, 크기, radius | 아니오 | 밝기가 바뀐다고 레이아웃이 바뀌지는 않는다 |
| 타입 스케일, 굵기 | 아니오 | 읽는 방식은 같다 |
| 모션 | 아니오 | 테마가 아니라 prefers-reduced-motion으로 처리한다 |
| 높이(elevation) | 예, 표현 방식이 바뀐다 | 거의 검은 바탕에서는 그림자가 보이지 않아 밝기로 대신한다 |
높이 표현 방식이 바뀐다
라이트 모드에서 떠 있는 표면은 그림자가 있는 흰색이다. 다크 모드에서는 #0F172A 위에 그림자가 보이지 않으므로, 떠 있는 표면을 더 밝게 만든다. #0F172A 위에 그보다 밝은 #1E293B를 올리는 식이다. --ds-shadow-* 토큰은 그대로 적용되지만 거의 보이지 않고, 나머지 구분은 --ds-border가 맡는다.
--ds-white를 고정 값이 아니라 시맨틱 토큰으로 둔 이유가 이것이다. 다크 모드에서는 #1E293B가 된다. 카드는 계속 "흰색"을 요청하고, 그게 실제로 어떤 색인지는 플랫폼이 정한다.
토큰 재매핑
다크 테마는 토큰을 60개 넘게 다시 매핑한다. 시맨틱 계층의 핵심 토큰은 다음과 같다.
| Token | Light | Dark |
|---|---|---|
--ds-bg | #FFFFFF | #0F172A |
--ds-surface | {gray-100} | #1E293B |
--ds-white | #FFFFFF | #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-link | #0F62FE | #458DFF |
--ds-focus | #0F62FE | #458DFF |
--ds-disabled-bg | {gray-300} | #334155 |
--ds-disabled-text | {gray-500} | #7E8EA3 |
--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로 통과한다.
기본 토큰은 건드리지 않는다. --ds-accent-600은 팔레트 문서와 Figma를 포함해 어디서나 #0F62FE다. 바뀌는 건 시맨틱 별칭뿐이다. 브랜드 스펙을 보는 사람에게 브랜드 파랑은 여전히 하나다.
피드백 세트
피드백 배경은 옅은 밝은 색에서 짙은 어두운 색으로 바뀌고, 텍스트는 같은 색조의 밝은 단계를 쓴다.
| State | Light bg / text | Dark bg / text |
|---|---|---|
| Info | {info-50} / {info-800} | #0C2340 / {info-400} #93C5E8 |
| Success | {green-50} / {green-800} | #0A2E1A / {green-300} #A3DFBA |
| Warning | {yellow-50} / {yellow-900} | #3D2008 / {yellow-300} #F0CE70 |
| Error | {red-50} / {red-800} | #3D1212 / {red-300} #EBADAD |
테두리는 두 테마 모두 400 단계를 쓴다. green-400 테두리는 green-50 위에서도 #0A2E1A 위에서도 잘 보인다. 덕분에 재매핑할 토큰이 네 개 줄고, drift가 생길 여지도 하나 줄어든다.
아이콘은 다르다. 라이트에서는 700 단계, 다크에서는 400 단계를 쓴다. 어두운 피드백 배경 위에 700을 그대로 두면 2.50–3.03:1로, 의미를 담은 그래픽에 필요한 3:1에 못 미친다.
항상 어두운 표면은 예외
카메라 뷰파인더와 QR 스캐너 표면은 다시 매핑하지 않는다. 두 테마 모두 #111827이고, 오버레이에는 밝은 값을 하드코딩한다. 라이트 모드에서 뷰파인더가 뒤집히면 사용자는 익숙한 기준을 잃을 뿐 얻는 게 없다. 시스템이 공식적으로 허용한 유일한 테마 예외다. 나중에 누군가 좋은 뜻으로 "고치지" 않도록 여기에 적어 둔다.
| Token | Light | Dark |
|---|---|---|
--ds-bg | ...white | ...navy-900 |
--ds-fg | ...gray-700 | ...primary-300 |
--ds-heading | ...primary-900 | ...primary-100 |
--ds-muted | ...gray-500 | ...primary-400 |
--ds-border | ...gray-200 | ...primary-700 |
--ds-surface | ...gray-100 | ...primary-800 |
--ds-white | ...#FFFFFF | ...#FFFFFF |
--ds-on-accent | ...#FFFFFF | ...#FFFFFF |
--ds-primary | ...accent-600 | ...accent-500 |
--ds-primary-hover | ...accent-700 | ...accent-400 |
--ds-primary-light | ...accent-50 | ...accent-900 |
--ds-primary-900 | ...gray-900 | ...gray-900 |
--ds-link | ...accent-600 | ...accent-500 |
--ds-focus | ...accent-600 | ...accent-500 |
--ds-disabled-bg | ...gray-200 | ...gray-700 |
--ds-disabled-text | ...gray-400 | ...gray-500 |
--ds-info-bg | ...info-50 | ...#0C2340 |
--ds-info-border | ...info-200 | ...info-700 |
--ds-info-text | ...info-800 | ...#93C5E8 |
--ds-success-bg | ...green-50 | ...#0A2E1A |
--ds-success-border | ...green-200 | ...green-700 |
--ds-success-text | ...green-800 | ...#A3DFBA |
--ds-warning-bg | ...yellow-50 | ...#3D2008 |
--ds-warning-border | ...yellow-200 | ...yellow-700 |
--ds-warning-text | ...yellow-900 | ...#F0CE70 |
--ds-error-bg | ...red-50 | ...#3D1212 |
--ds-error-border | ...red-200 | ...red-700 |
--ds-error-text | ...red-800 | ...#EBADAD |
테마 전환
테마는 .ds-layout 요소에 ds-dark 클래스를 붙였다 떼서 적용한다. 모든 값이 CSS 사용자 정의 속성을 거치므로, 클래스 하나만 바꾸면 전환되고 다시 렌더링하거나 화면이 번쩍이는 일이 없다.
| Mode | Behavior |
|---|---|
| Class toggle | .ds-layout.ds-dark. 사용자가 직접 고른 값이며 저장된다 |
| System preference | 처음 로드할 때 prefers-color-scheme을 따른다 |
다크 팔레트는 hydration 이후가 아니라 첫 페인트 전에 적용한다. 새벽 6시에 화면이 하얗게 번쩍이는 송금 앱은 차라리 다크 모드가 없는 앱보다 못하다.
디자인 가이드라인
- 라이트 모드로 먼저 디자인하고, 다크 모드를 검증한다. 라이트 모드는 배경을 더 밝힐 여지가 없어서 대비를 맞추기가 더 어렵다.
- 컴포넌트에 색을 하드코딩하지 않는다. hex 값을 직접 쓰면 테마가 그 색을 바꿀 수 없다.
- 다크 모드 표면에 순수한 검정(
#000000)이나 순수한 흰색을 쓰지 않는다.#0F172A와#F1F5F9를 쓰면 구분은 유지하면서 OLED에서 글자가 번져 보이는 현상을 줄일 수 있다. - 다크 모드에서는 그림자 대신 밝기로 표면을 띄운다.
- 다크 모드 값을 바꾸면 상태 색을 전부 다시 확인한다. 작은 대비 실수 하나로 사용자가 "completed"를 "pending"으로 잘못 읽을 수 있는 곳이 상태 표시다.
- 문서의 스크린샷은 두 테마 모두로 찍는다. 라이트 모드에서만 본 컴포넌트는 리뷰를 마친 게 아니다.
권장과 금지
| Do | Don't |
|---|---|
Remap --ds-primary and let components inherit | Add a dark-mode override to a component |
| Keep spacing and type identical across themes | "Tighten" dark mode because it looks heavier |
Let --ds-white mean level-1 surface | Write #FFFFFF when you mean a card |
Use --ds-border to separate dark surfaces | Rely on a shadow that is invisible on #0F172A |
| Test status chips in both themes | Assume a passing light ratio implies a passing dark one |
color: var(--ds-fg);Use semantic tokens. They adapt to both themes automatically.
color: #374151;Hardcoded hex values break in the opposite theme. gray-700 on dark bg = 2.5:1 contrast (fail).
접근성
두 테마 모두 WCAG 2.2 AA를 목표로 한다. 아래 비율은 추정치가 아니라 측정값이다.
다크 모드 (검증됨)
| Pairing | Foreground | Background | Ratio | Pass |
|---|---|---|---|---|
| 본문 텍스트 | #CBD5E1 | #0F172A | 12.02:1 | ✓ |
| 표면 위 본문 | #CBD5E1 | #1E293B | 9.85:1 | ✓ |
| 제목 | #F1F5F9 | #0F172A | 16.30:1 | ✓ |
| 흐린 텍스트 | #94A3B8 | #0F172A | 6.96:1 | ✓ |
| 표면 위 흐린 텍스트 | #94A3B8 | #1E293B | 5.71:1 | ✓ |
| Primary 링크 | #458DFF | #0F172A | 5.53:1 | ✓ |
| 표면 위 Primary 링크 | #458DFF | #1E293B | 4.53:1 | ✓ |
| 비활성 텍스트 | #7E8EA3 | #0F172A | 5.34:1 | ✓ |
| Info 텍스트 | #93C5E8 | #0C2340 | 8.57:1 | ✓ |
| Success 텍스트 | #A3DFBA | #0A2E1A | 9.74:1 | ✓ |
| Warning 텍스트 | #F0CE70 | #3D2008 | 9.77:1 | ✓ |
| Error 텍스트 | #EBADAD | #3D1212 | 8.59:1 | ✓ |
| 상태 배지, Pending | #F0CE70 | #3D2008 | 9.77:1 | ✓ |
| 상태 배지, Sent / Processing | #93C5E8 | #0C2340 | 8.57:1 | ✓ |
| 상태 배지, Completed | #A3DFBA | #0A2E1A | 9.74:1 | ✓ |
| 상태 배지, Failed | #EBADAD | #3D1212 | 8.59:1 | ✓ |
| 상태 배지, Cancelled | #94A3B8 | #1E293B | 5.71:1 | ✓ |
| 채움 위의 비활성 버튼 라벨 | #7E8EA3 | #334155 | 3.10:1 | 면제 (SC 1.4.3) |
| Tip 텍스트 | #A6C8FF | #0F2847 | 8.71:1 | ✓ |
| 사이드바 활성 링크 | #458DFF | #0F2847 | 4.60:1 | ✓ |
| Primary 버튼 위 라벨 | #0F172A | #458DFF | 5.53:1 | ✓ |
| Primary 버튼 위 라벨, Hover | #0F172A | #3079FF | 4.51:1 | ✓ |
| Placeholder | #8391A6 | #0F172A | 5.58:1 | ✓ |
| 표면 위 Placeholder | #8391A6 | #1E293B | 4.57:1 | ✓ |
해결됨: 다크 Primary 버튼 라벨
다크 모드에서는 --ds-on-accent를 #0F172A로 다시 매핑한다. 그 전에는 --ds-primary가 #458DFF로 밝아지는 동안 이 값은 #FFFFFF로 남아 있었다. 그래서 라벨 대비가 기본 상태 3.23:1, Hover 3.96:1로, 14px semibold 라벨에 필요한 4.5:1에 못 미쳤다.
| State | Label | Fill | Ratio |
|---|---|---|---|
| Rest | #0F172A | #458DFF | 5.53:1 ✓ |
| Hover | #0F172A | #3079FF | 4.51:1 ✓ |
Hover는 기준을 0.01 차이로 겨우 넘는다. 앞으로 다크 모드의 --ds-primary-hover를 바꾸려면, 출시 전에 반드시 이 조합으로 다시 측정한다.
검토했다가 버린 대안도 있다. --ds-primary를 #1E6BFF로 어둡게 하고 흰 라벨을 유지하는 방법(4.56:1)이다. 이렇게 하면 버튼이 페이지 배경에 묻힌다. 재매핑하면 #0F172A 대비 5.53:1이 유지되지만, 이 방법으로는 3.91:1에 그친다. 화면에서 가장 중요한 요소가 약해졌을 것이다.
그 밖의 확인 사항
- 포커스 링은
--ds-focus를 기준으로color-mix()를 쓰므로, 별도 토큰 없이도 다크 모드에서 알맞은 색으로 다시 계산된다. - 동작 줄이기는 테마와 상관없이 Platform 계층에서 처리한다.
forced-colors/ Windows 고대비 모드는 어느 테마에서도 구현하거나 테스트하지 않았다.- 두 테마 모두 같은 조합 목록으로 검증한다. 다크만 확인하고 넘어가서 라이트 모드 회귀를 놓치는 일은 없다.