Skip to main content
ENKO

테마

Senco는 라이트와 다크, 두 가지 테마를 제공한다. 테마는 또 하나의 디자인 시스템이 아니라 시맨틱 계층을 다시 매핑한 것이다. 기본 팔레트, 간격 스케일, 타입 스케일, 컴포넌트 계약은 두 테마에서 모두 같다.

이 구조 덕분에 다크 모드를 유지보수할 수 있다. 시맨틱 토큰으로 만든 컴포넌트는 출시 첫날부터 다크 모드에서도 제대로 보인다. 처음부터 색 이름을 직접 쓴 적이 없기 때문이다.

테마 종류

ThemeScope selectorPage backgroundDefault
Light.ds-layout#FFFFFF✓
Dark.ds-layout.ds-dark#0F172A—

세 번째 범위인 .senco-prototype-scope는 기기 프레임 프로토타입 안에서 라이트 값을 고정한다. 문서 페이지가 다크여도 모바일 목업까지 다크로 바뀌지 않게 하려는 것이다. 테마가 아니라 플랫폼 대상이다. Tokens 참고.

테마 동작 방식

테마는 여섯 개 토큰 계층 중 다섯 번째인 Platform 계층에 있다. 규칙은 일부러 좁게 잡았다.

  1. 테마는 이미 있는 토큰만 다시 매핑할 수 있다. 테마 블록 안에서 새 토큰을 만들면 결함이다. 한 테마에만 있는 기능이 생긴다는 뜻이기 때문이다.
  2. 테마는 기본 계층이 아니라 시맨틱 계층을 다시 매핑한다. --ds-accent-600은 두 테마 모두 #0F62FE다. 바뀌는 건 --ds-primary가 더 이상 이 값을 가리키지 않는다는 점이다.
  3. 컴포넌트 토큰은 따로 손대지 않아도 따라온다. --ds-actionbtn-primary-bg는 var(--ds-primary)를 참조하므로 다크 모드 값을 따로 둘 필요가 없다.
  4. 컴포넌트는 테마에 따라 분기하지 않는다. 어떤 컴포넌트에도 isDark는 없다. 컴포넌트가 테마를 알아야 한다면 토큰이 하나 빠진 것이다.

실제로 바뀌는 것

CategoryRemapped in dark?Why
시맨틱 색예테마를 두는 목적 자체
컴포넌트 색 토큰시맨틱 재매핑만으로 부족한 곳만사이드바, 코드, 테이블, 라디오 카드
간격, 크기, radius아니오밝기가 바뀐다고 레이아웃이 바뀌지는 않는다
타입 스케일, 굵기아니오읽는 방식은 같다
모션아니오테마가 아니라 prefers-reduced-motion으로 처리한다
높이(elevation)예, 표현 방식이 바뀐다거의 검은 바탕에서는 그림자가 보이지 않아 밝기로 대신한다

높이 표현 방식이 바뀐다

라이트 모드에서 떠 있는 표면은 그림자가 있는 흰색이다. 다크 모드에서는 #0F172A 위에 그림자가 보이지 않으므로, 떠 있는 표면을 더 밝게 만든다. #0F172A 위에 그보다 밝은 #1E293B를 올리는 식이다. --ds-shadow-* 토큰은 그대로 적용되지만 거의 보이지 않고, 나머지 구분은 --ds-border가 맡는다.

--ds-white를 고정 값이 아니라 시맨틱 토큰으로 둔 이유가 이것이다. 다크 모드에서는 #1E293B가 된다. 카드는 계속 "흰색"을 요청하고, 그게 실제로 어떤 색인지는 플랫폼이 정한다.

Side-by-side theme comparison
Light theme
JD
Fatima B.
Transfer to USD wallet
$120.00
Completed
Dark theme
JD
Fatima B.
Transfer to USD wallet
$120.00
Completed

토큰 재매핑

다크 테마는 토큰을 60개 넘게 다시 매핑한다. 시맨틱 계층의 핵심 토큰은 다음과 같다.

TokenLightDark
--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다. 바뀌는 건 시맨틱 별칭뿐이다. 브랜드 스펙을 보는 사람에게 브랜드 파랑은 여전히 하나다.

피드백 세트

피드백 배경은 옅은 밝은 색에서 짙은 어두운 색으로 바뀌고, 텍스트는 같은 색조의 밝은 단계를 쓴다.

StateLight bg / textDark 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이고, 오버레이에는 밝은 값을 하드코딩한다. 라이트 모드에서 뷰파인더가 뒤집히면 사용자는 익숙한 기준을 잃을 뿐 얻는 게 없다. 시스템이 공식적으로 허용한 유일한 테마 예외다. 나중에 누군가 좋은 뜻으로 "고치지" 않도록 여기에 적어 둔다.

Live token remap table
TokenLightDark
--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 사용자 정의 속성을 거치므로, 클래스 하나만 바꾸면 전환되고 다시 렌더링하거나 화면이 번쩍이는 일이 없다.

ModeBehavior
Class toggle.ds-layout.ds-dark. 사용자가 직접 고른 값이며 저장된다
System preference처음 로드할 때 prefers-color-scheme을 따른다

다크 팔레트는 hydration 이후가 아니라 첫 페인트 전에 적용한다. 새벽 6시에 화면이 하얗게 번쩍이는 송금 앱은 차라리 다크 모드가 없는 앱보다 못하다.

디자인 가이드라인

  • 라이트 모드로 먼저 디자인하고, 다크 모드를 검증한다. 라이트 모드는 배경을 더 밝힐 여지가 없어서 대비를 맞추기가 더 어렵다.
  • 컴포넌트에 색을 하드코딩하지 않는다. hex 값을 직접 쓰면 테마가 그 색을 바꿀 수 없다.
  • 다크 모드 표면에 순수한 검정(#000000)이나 순수한 흰색을 쓰지 않는다. #0F172A와 #F1F5F9를 쓰면 구분은 유지하면서 OLED에서 글자가 번져 보이는 현상을 줄일 수 있다.
  • 다크 모드에서는 그림자 대신 밝기로 표면을 띄운다.
  • 다크 모드 값을 바꾸면 상태 색을 전부 다시 확인한다. 작은 대비 실수 하나로 사용자가 "completed"를 "pending"으로 잘못 읽을 수 있는 곳이 상태 표시다.
  • 문서의 스크린샷은 두 테마 모두로 찍는다. 라이트 모드에서만 본 컴포넌트는 리뷰를 마친 게 아니다.

권장과 금지

DoDon't
Remap --ds-primary and let components inheritAdd 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 surfaceWrite #FFFFFF when you mean a card
Use --ds-border to separate dark surfacesRely on a shadow that is invisible on #0F172A
Test status chips in both themesAssume a passing light ratio implies a passing dark one
Do / Don't: semantic tokens vs hardcoded values
Do
color: var(--ds-fg);
Light: readable
Dark: readable

Use semantic tokens. They adapt to both themes automatically.

Don't
color: #374151;
Light: readable
Dark: invisible

Hardcoded hex values break in the opposite theme. gray-700 on dark bg = 2.5:1 contrast (fail).

접근성

두 테마 모두 WCAG 2.2 AA를 목표로 한다. 아래 비율은 추정치가 아니라 측정값이다.

다크 모드 (검증됨)

PairingForegroundBackgroundRatioPass
본문 텍스트#CBD5E1#0F172A12.02:1✓
표면 위 본문#CBD5E1#1E293B9.85:1✓
제목#F1F5F9#0F172A16.30:1✓
흐린 텍스트#94A3B8#0F172A6.96:1✓
표면 위 흐린 텍스트#94A3B8#1E293B5.71:1✓
Primary 링크#458DFF#0F172A5.53:1✓
표면 위 Primary 링크#458DFF#1E293B4.53:1✓
비활성 텍스트#7E8EA3#0F172A5.34:1✓
Info 텍스트#93C5E8#0C23408.57:1✓
Success 텍스트#A3DFBA#0A2E1A9.74:1✓
Warning 텍스트#F0CE70#3D20089.77:1✓
Error 텍스트#EBADAD#3D12128.59:1✓
상태 배지, Pending#F0CE70#3D20089.77:1✓
상태 배지, Sent / Processing#93C5E8#0C23408.57:1✓
상태 배지, Completed#A3DFBA#0A2E1A9.74:1✓
상태 배지, Failed#EBADAD#3D12128.59:1✓
상태 배지, Cancelled#94A3B8#1E293B5.71:1✓
채움 위의 비활성 버튼 라벨#7E8EA3#3341553.10:1면제 (SC 1.4.3)
Tip 텍스트#A6C8FF#0F28478.71:1✓
사이드바 활성 링크#458DFF#0F28474.60:1✓
Primary 버튼 위 라벨#0F172A#458DFF5.53:1✓
Primary 버튼 위 라벨, Hover#0F172A#3079FF4.51:1✓
Placeholder#8391A6#0F172A5.58:1✓
표면 위 Placeholder#8391A6#1E293B4.57:1✓

해결됨: 다크 Primary 버튼 라벨

다크 모드에서는 --ds-on-accent를 #0F172A로 다시 매핑한다. 그 전에는 --ds-primary가 #458DFF로 밝아지는 동안 이 값은 #FFFFFF로 남아 있었다. 그래서 라벨 대비가 기본 상태 3.23:1, Hover 3.96:1로, 14px semibold 라벨에 필요한 4.5:1에 못 미쳤다.

StateLabelFillRatio
Rest#0F172A#458DFF5.53:1 ✓
Hover#0F172A#3079FF4.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 고대비 모드는 어느 테마에서도 구현하거나 테스트하지 않았다.
  • 두 테마 모두 같은 조합 목록으로 검증한다. 다크만 확인하고 넘어가서 라이트 모드 회귀를 놓치는 일은 없다.