21 KiB
Theme & Token Architecture
AI Theme 기본 적용 정책 (2026-10-07 사용자 확정)
사용자가 별도로 "테마를 적용해줘", "Semantic Token을 사용해줘"라고 요청하지 않아도 모든 AI 신규·수정 UI는 Theme을 기본 적용한다. Theme은 Component 탐색·설계 단계부터 포함되는 구현 조건이다. 전체 구현 순서는 AI/Component 구현 원칙을 따른다.
- 설계 계층은 Primitive → Semantic → Component Mapping을 유지한다.
- 값 선택 순서는 Existing Semantic Token → 해당 Existing Component Token → Existing CSS Variable → 필요한 경우 최소 Semantic 확장이다. 이는 탐색 순서이며 Component Mapping 계층을 제거하거나 다른 Component의 내부 Token을 직접 공유하는 규칙이 아니다.
- 기존 Token이 있는 색상·spacing·radius·typography·값을 먼저 고정값으로 작성하지 않는다. 같은 Token을 여러 파일에 중복 정의하지 않는다. 정의되지 않은 속성은 기존 shadcn 기본 구현과 구조적 값의 계약을 유지하고 임의 Token을 양산하지 않는다.
- 특정 Reference 색상을 그대로 재현하라는 요청에도 먼저 의미가 맞는 기존 Theme / Semantic Token에 연결한다. 정확한 값이 기존 Theme과 다르면 Theme 값 변경의 필요성과 영향을 확인한다. 독립 Reference 검증 표현은 Story 범위와 Production Theme 대상 여부를 먼저 확인하고 예외 사유를 기록한다.
/* 금지: UI에서 Theme 색상을 고정 */
.status { color: #00C896; }
/* 기존 의미에 맞는 Token 연결 예: 실제 진행 control 색 역할일 때 */
.status { color: var(--hm-sem-state-active); }
위 예는 상태 역할이 실제로 일치할 때만 적용한다. Dashboard 업무 Status는 기존 mapping을 먼저 확인하며 모든 업무 상태를 Control Active에 합치지 않는다. 문서의 Token·색상 예시는 현재 코드/계약과 대조하고 존재하지 않는 Token을 실제 값처럼 사용하지 않는다.
Theme 사용과 Theme Source 수정
| 작업 | 기본 처리 |
|---|---|
| 기존 Token / CSS Variable을 UI에서 사용 | 별도 승인 없이 기본 적용 |
themes/default.json, Primitive / Semantic 값 자체 변경 |
요청 충족에 필요한 명시적 사유와 영향을 확인한 뒤 진행 |
| 새 Semantic / 공개 Runtime Token 추가 | 기존 계약으로 해결 불가능한 경우 최소 확장; 소비처·fallback·호환성 및 package 변경 범위 확인 |
Theme 적용 요청을 Theme Source 수정 허가로 해석하지 않는다. UI를 연결하기 위해 Default/Primitive 값을 임의 변경하지 않는다. Color Source of Truth는 themes/*.json이며 생성된 fallback CSS/데이터를 별도 원본처럼 수동 수정하지 않는다.
Runtime과 독립 fallback
themes/*.json
→ Runtime Theme Loader
→ CSS Custom Properties
→ Semantic
→ Component Mapping
→ UI
앱 초기 CSS에서 기존 @hm-design/ui/theme.css 적용을 확인하고, Theme 주입이 없으면 HM Default를 사용한다. 이미 적용된 Theme 환경과 공개 Runtime API를 재사용하며 별도 Theme 엔진을 만들지 않는다. Global / Scoped / Portal 경계와 partial / invalid / cleanup 계약은 Runtime Theme를 따른다.
기존 공개 계약이 지원하는 color-only 변경은 Theme JSON만 별도 배포해 반영할 수 있어야 하며 UI package 재배포를 요구하지 않도록 Component에 값을 고정하지 않는다. 새 Token·CSS 규칙·Runtime 계약 변경은 package 변경이며 Theme JSON만으로 처리할 수 없다. 실제 배포 경로는 Theme delivery를 따른다.
Primary와 On Accent는 독립 fallback을 가진 sibling Semantic이다. Primary override가 On Accent를 변경하게 만들거나 둘을 alias하지 않는다. Active / Focus / Destructive 및 Global / Scoped의 기존 독립성도 유지한다. 같은 Default 값이라는 이유로 fallback을 합치거나 새 상호 의존성을 만들지 않는다.
Theme 미적용 prototype / 독립 POC 등의 예외는 사용자의 명시적 요청과 기존 시스템에 영향 없는 별도 범위가 있을 때만 AI 구현 예외 기준에 따라 적용한다. 이 확정 정책은 아래 과거 예시의 상충 문구보다 우선한다.
목적
HMDesignSystem은 shadcn/ui를 기반으로 하며 하나의 HM 기본 테마를 운영한다.
HM Default는 기본 제공 Theme preset이다. 외부 서비스 Theme는 같은 공개 Runtime 계약을 사용해 값만 교체한다. Theme selector UI, Light/Dark 엔진은 만들지 않는다. 실제 계약/API와 scope 정책은 Runtime Theme를 따른다.
디자인 변경 시 가능하면 React 컴포넌트 코드를 수정하지 않고 Theme / Token CSS 수정만으로 반영할 수 있는 구조를 유지한다.
기본 구조
HMDesignSystem의 스타일 값은 기본적으로 다음 구조를 따른다.
Primitive
→ Semantic (공통 Theme 역할)
→ Component Mapping Token
→ Component
컬러 Token은 Primitive → Semantic → Component Mapping의 3계층으로 구분한다. 공통 Theme에는 typography/radius/geometry도 포함하며, 선택적 공개 palette 사용과 컴포넌트 고유 geometry는 현재 구현 계약을 따른다.
Global Theme Token
여러 컴포넌트에서 공통으로 사용하는 디자인 정책을 관리한다.
주요 관리 대상:
- Color
- Typography
- Radius
- Shadow
- Control Height
- 공통 Spacing
- 공통 상태 표현
현재 HM 공통 Token 예(컬러 원본은 themes/default.json, 아래 CSS는 생성 결과의 일부):
:root, [data-hm-theme] {
--hm-sem-action-primary: var(--hm-color-primary);
--hm-sem-bg-surface: var(--hm-color-surface-default);
--hm-sem-stroke-input-default: var(--hm-color-border-control);
--hm-radius-input: var(--hm-radius-control-md);
}
공통 디자인 정책이 변경되는 경우 Global Theme Token을 수정하여 관련된 여러 컴포넌트에 함께 반영할 수 있어야 한다.
Component Mapping Token
각 컴포넌트는 공통 Theme 역할을 Component Mapping Token을 통해 사용한다.
예:
--hm-button-radius: var(--hm-radius-button);
--hm-input-radius: var(--hm-radius-input);
--hm-select-radius: var(--hm-radius-input);
--hm-input-border-color: var(--hm-sem-stroke-input-default);
--hm-select-border-color: var(--hm-sem-stroke-input-default);
--hm-card-border-color: var(--hm-sem-stroke-input-default);
이 구조를 통해 공통 변경과 개별 변경을 모두 지원한다.
예를 들어:
--hm-radius-input: 6px;
로 변경하면 이를 사용하는 Input, Select 등이 함께 변경된다. Button radius는 별도 Mapping을 따른다.
반대로 특정 컴포넌트만 변경해야 할 경우:
--hm-input-radius: 8px;
처럼 Component Mapping Token만 변경할 수 있다.
Component 적용 원칙
컴포넌트 내부에는 실제 색상값이나 공통 디자인 값을 가능한 직접 작성하지 않는다.
피해야 하는 예:
className="bg-[#2563eb] border-[#d1d5db] rounded-[8px]"
대신 Theme / Component Mapping Token을 CSS selector에서 사용한다.
예:
[data-slot="input"] {
border-color: var(--hm-input-border-color);
border-radius: var(--hm-input-radius);
}
shadcn의 기존 구조와 CVA variant 구조는 가능한 유지한다.
HMDesignSystem을 위해 shadcn 컴포넌트를 완전히 별도 구조로 재작성하지 않는다.
공통화 기준
현재 값이 같다는 이유만으로 무조건 하나의 Component Token으로 합치지 않는다.
반대로 모든 값을 컴포넌트별 Token으로 만들지도 않는다.
판단 기준은 다음과 같다.
Global Theme Token
여러 컴포넌트에서 동일한 디자인 정책으로 함께 변경되어야 하는 값
예:
- 기본 Border Color
- 기본 Radius
- Primary Color
- Control Height
- 기본 Typography
Component Mapping Token
특정 컴포넌트에서 향후 독립적으로 변경될 가능성이 있는 값
예:
- Button Primary Background
- Input Border
- Dialog Background
- Checkbox Checked Color
State 관리
실제로 존재하는 상태에 대해서만 Token을 구성한다.
Control Active, Persistent Selected Surface, Navigation Current/Open은 서로 구분한다. Focus는 Active와 sibling Semantic이고 GNB/LNB는 contextual navigation focus 예외를 유지한다. Invalid는 Destructive border, Invalid + Focus는 Destructive border + Focus indication이다. 컴포넌트별 적용 범위는 해당 Component/Pattern 문서와 아래 HM Token 연결 절을 따른다.
예:
- default
- hover
- active / pressed
- focus
- selected / checked
- disabled
- error / invalid
size × variant × state × property 형태로 무조건 세분화하여 Token 수를 늘리지 않는다.
실제 디자인 차이가 있는 경우에만 분리한다.
단일 Theme 원칙
HM Default preset은 하나이며 Light/Dark preset이나 사용자 Theme selector는 없다. 공개 Runtime API는 동일 계약의 외부 값과 독립 Scoped override를 지원한다. 아래 제한은 전환 UI/제품별 Theme catalog를 새로 설계하지 않는다는 의미이며 구현된 Global/Scoped 적용을 금지하지 않는다.
현재 필요하지 않은 구조:
- ThemeProvider 기반 테마 전환
- Light / Dark Theme Switching
- 별도 Multi Theme 전환 UI/catalog
- 별도 Multi Brand catalog
- 사용자 Theme Selector
- Runtime Theme Selector
향후 확장은 가능하게 유지하되, 미래 가능성을 위해 현재 구조를 복잡하게 만들지 않는다.
작업 원칙
새로운 컴포넌트를 추가할 때 다음 순서로 판단한다.
- 기존 Global Theme Token으로 표현 가능한지 확인
- 공통 디자인 정책이라면 기존 Global Theme Token 재사용
- 독립 변경 가능성이 있다면 Component Mapping Token 추가
- 컴포넌트에는 실제 디자인 값을 직접 작성하지 않음
- Storybook에서도 실제 소비 프로젝트와 동일한 Component API 사용
HMDesignSystem의 핵심 원칙은 다음과 같다.
공통 디자인 정책은 Theme Token 하나를 수정하여 관련 컴포넌트에 함께 반영할 수 있어야 한다.
동시에:
특정 컴포넌트만 변경해야 할 경우 Component Mapping Token을 통해 독립적으로 override할 수 있어야 한다.
현재 구현 기준
Token 이름과 위치
위 예시의 Token 이름은 개념 설명용이다. 외부 ui_viz Reference (ZIP 미포함, 읽기 전용)는 초기 디자인 참고 자료다. 현재 Production 코드와 실제 Storybook을 1차 기준으로 Component/Pattern 문서의 HM 결정과 대조한다. 실제 저장소 기준은 다음과 같다.
| 계층 | 위치 | 이름 |
|---|---|---|
| Primitive | packages/ui/src/theme/primitives.css |
--hm-<family>-<step> — ui_viz Primitive 이름 그대로 |
| 공통 Theme (Global/Semantic) | packages/ui/src/theme/default.css |
--hm-color-*, --hm-radius-*, --hm-control-height-*, --hm-font-*, --hm-sem-* |
| shadcn/Tailwind adapter | default.css 하단 |
--primary, --border, --radius 등을 HM Token에 연결 |
| Component Mapping | packages/ui/src/theme/<component>.css |
--hm-<component>-<속성> |
- Primitive: ui_viz와 다른 이름을 만들지 않고 변환 대응표도 두지 않는다. ui_viz에 Primitive로 등록되지 않은 값(예: Button 범위 Component 값)은 Primitive로 올리지 않는다.
- Component Mapping: ui_viz에 등록된 Component Token(예:
button.radius,button.height.md)은 그 속성 이름을 그대로 쓴다(--hm-button-radius,--hm-button-height-md). ui_viz의 CSS 변수는--button-radius처럼 접두어가 없지만, HM은 모든 Token에--hm-을 붙인다. Tailwind v4의--color-*·--radius-*같은 theme namespace와 소비 앱 변수와의 충돌을 피하기 위해서다. ui_viz에 없는 값은 같은 형식으로 정의한다. - 파일: 컴포넌트 전용 Mapping Token과
[data-slot]selector 스타일은packages/ui/src/theme/<component>.css에 두고packages/ui/src/theme.css가 import한다. 공통 값은default.css에 둔다. Demo/App 페이지 스타일은 넣지 않는다. 컴포넌트 값은 Tailwind arbitrary value(bg-[var(...)])가 아니라 이 CSS의 selector가 Mapping Token을 참조해 적용한다. - Mapping Token의 기본값은 공통 Theme Token을 참조하고(
--hm-button-radius: var(--hm-radius-button)), 해당 컴포넌트만 따로 바꿀 수 있다. - Focus Color(
default.css):--hm-color-border-focus→--hm-sem-stroke-input-focus→--ring한 줄 체인이 일반 Focus 색(Input·Textarea·Select·Button·Checkbox·Tabs·Slider·Menubar 등)의 공통 소스다. 현재 시험 적용값은 Light 2(--hm-green-light-2,#266651)이며 이전 값은--hm-color-primary(#123328)였다. 사용자 최종 확정 전이다. Semantic 값·이름, Runtime 계약과 Theme JSON 구조는 유지한다.- 2026-10-06 Focus 정합: Slider Hover/Drag halo는 기존 Primary 20% / 4·6px를 유지하고, 키보드 Focus halo만 Focus Semantic 20%로 분리한다. Focus + Hover는 4px Focus, Focus + Drag는 6px Focus가 우선하며 Disabled는 후광이 없다. Range·Thumb border는 Active Semantic을 유지한다.
- Menubar 상위 Trigger Highlighted/Open 면은 Accent 100%를 유지하고 키보드 Focus에 Navigation Menu와 같은 3px
--ring50% 독립 ring을 더한다. Open + Focus는 Accent 면과 Focus ring을 함께 표시한다. Item Highlighted는 Accent 45%, Disabled는 기존 규칙을 유지한다. Navigation Menu는 변경하지 않는다. 일반 Story의 computed style로 검증하고 Designer 전용 button outline을 근거로 사용하지 않는다.
- Active State Semantic(
default.css, 2026-10-06):--hm-sem-state-active(→--hm-sem-palette-green-light-2→--hm-green-light-2,#266651)와--hm-sem-on-state-active(→--hm-color-text-inverse, 흰색, 대비 약 6.8:1). Checked·Selected control·ON·Active fill을 포괄하며 Navigation/Pagination Current는 포함하지 않는다. Checkbox checked, Radio selected(border·dot), Switch on, Slider range·thumb border, Progress indicator, Calendar single selected·range start/end의--hm-<component>-*가 이를 참조한다. Badge default와 Pagination current는 대상이 아니며 Primary Green(--hm-sem-action-primary)을 쓴다. 이 한 줄을 바꾸면 위 컴포넌트가 함께 바뀌고 Focus는 영향을 받지 않는다.- Focus chain(
--hm-color-border-focus)과 sibling이다. 현재 값은 둘 다 Light 2지만 의미가 다르므로 어느 쪽도 다른 쪽을 참조하지 않는다. 같은 색일 때 fill(Active)과 바깥 ring(Focus)의 형태로 구분한다. - Progress는 현재 Active와 색 정책이 같아 같은 Semantic을 쓴다. 의미가 달라지면 별도 Semantic으로 분리한다.
- 대상 아님: Toggle / Toggle Group(on =
--accent면, 별도 정책), GNB·LNB·Navigation Menu current(기존 Strong Brand 언어), Error/Destructive, Button. - Persistent Selected Surface: Toggle / Toggle Group의 Selected 및 Selected + Hover =
--accent100% /--accent-foreground(Git baselined101530매핑, 2026-10-06 원복). 아이콘은 전경을 따르고 border는 기존 default 투명 / outline input stroke를 유지한다. Active override에 영향받지 않고 Accent override를 따른다. Focus override는 별도 ring만 바꾼다. - Color 역할 정리: Hover / Soft surface =
--accent45% (Select·Command·Combobox·Menu Item highlighted, Calendar hover·range middle, Pagination hover, Table row hover·selected) / Persistent Selected Surface =--accent100%(Toggle·Toggle Group) / Menubar Top Trigger Highlighted·Open 및 Navigation Menu Trigger·Open =--accent100% + On Accent / Active Control fill =--hm-sem-state-active(위 적용 대상) / Focus = Focus chain / Strong Brand·Navigation·Badge default·Pagination current = Primary Green(--hm-sem-action-primary,#123328) / Error = Destructive. - Runtime 공개 계약(
packages/ui/src/theme/contract.ts)에 Active/on Active를 포함했다.src/theme/preview.ts는 패키지 Runtime API의 호환 어댑터다.
- Focus chain(
- Floating Surface Semantic(
default.css):--hm-sem-floating-background|border-width|border-color|radius|shadow. Dropdown Menu·Context Menu·Popover·Hover Card·Menubar content·Navigation Menu panel의--hm-<component>-*가 이를 참조한다. Toast(Sonner)는 notification surface라 이를 쓰지 않고--hm-toast-*를 따로 둔다(Component Token은 유지하며 컴포넌트별 override 가능). Date Picker·Combobox는 Popover를 조합하므로 별도 mapping이 없다. Tooltip(어두운 면)·Dialog/Alert Dialog/Sheet/Drawer(modal)·Select·Card·Command는 대상이 아니다.
[임시 — 정리되면 삭제] 현재 코드와 위 기준의 차이
- Primitive 이름:
--hm-warm-neutral-{light,mid,dark}는 ui_vizhm-neutral-warm-*,--hm-beige-light는hm-neutral-beige-light,--hm-green-tint(#BDDFD4)는hm-fg-on-green-secondary이다. ui_viz Primitive에 없는 이름:--hm-neutral-gray-500(#999999, ui_viz는color.placeholder),--hm-warm-neutral-pale(#DEDBD2, ui_viz는 Primitive 없이color.stroke.input.default),--hm-green-tone-1~4(Button 범위 gradient 값),--hm-warm-neutral-secondary(#EDE8DB),--hm-warm-neutral-muted(#F4F1EA, 두 값은 ui_viz 문서에서 찾지 못함). Button 작업으로 ui_viz 이름 그대로 추가한 Primitive:--hm-brown-light-2/-1,--hm-brown-dark-1,--hm-orange-light-4,--hm-orange-default.- Button Mapping Token은 ui_viz 이름(
--hm-button-radius,--hm-button-height-sm/md/lg,--hm-button-brand-{green,brown,orange}-{background,border},--hm-button-solid-*등)을 따른다. Gray(#767676/#5A5A5A), Brown·Orange gradient의 일부 stop은 ui_viz가 Button 범위 Component 값으로 정의한 값이라 초기에는 Primitive 없이button.css에 두었으나 현재는default.css의 공개 Theme palette로 이동했다(아래 Runtime 절).--hm-radius-button과--hm-radius-input(Input의--hm-input-radius, Select의--hm-select-radius가 참조)은 공통--hm-radius-control-md(4px)를 참조하며 컨테이너도 같은 값을 쓴다.--hm-radius-select는 Select 작업 후 참조하는 곳이 없다. ui_viz는radius.input.default4px와radius.container.default4px를 값이 같아도 alias하지 않는다. ui_viz는 Button radius를 Button 범위 Component 값(button.radius4px)으로 두고 다른 컴포넌트로 확대 해석하지 않는다고 적는다.
HM 값과 shadcn 기본값
- ui_viz에
Confirmed규격이 있는 속성 → Theme Token 적용 - 정의가 없거나
TBD·HOLD등 미확정인 속성 → 새 Token을 만들지 않고 shadcn 기본 구현 유지
Light / Dark
현재 HM Theme Token에는 Light/Dark 구분이 없다. 하나의 HM Theme 값만 정의하며, Dark 전용 값이나 Light/Dark 분기를 추가하지 않는다. 실제 디자인 요구로 Light/Dark가 정의되면 그 시점에 Token 구조를 검토한다.
2026-10-06 Runtime 구조 적용
이 절과 Runtime Theme가 위 초기 시제품 상태 설명보다 우선한다. 모든 Component Mapping은 기존 이름을 유지하고 Semantic / 선택적 Theme palette를 소비한다. Button의 이전 로컬 HEX는 Default preset의 공개 팔레트로 옮겼다. 외부 주입은 공개 Semantic/palette만 허용하고 Component별 Token은 입력 계약에 포함하지 않는다. Default 값과 외부 값은 같은 계약을 사용한다. Accent 45%는 내부 surface-highlight Semantic으로 공통화했다. 전체 계약과 Default 값은 계약표, 실제 적용·제한은 Runtime Theme를 참고한다.
후속: 디자인팀 컬러 Runtime 편집 — 2026-10-06
현재 Storybook의 Theme/Designer Playground/Editor에서 JSON URL/파일을 Preview하고 상위 컬러 17개 역할을 편집·Reset·Export/Import할 수 있다. Component Token은 직접 수정하지 않는다. 운영 컬러 원본은 themes/default.json/서비스 JSON이며 package fallback CSS/데이터는 생성한다. Production은 @hm-design/ui/theme의 URL Loader/Apply를 사용한다. Theme artifact는 UI package와 별도 배포한다. 상세 절차/제한은 디자인팀 Theme 가이드, Theme delivery, Runtime 적용·제한은 Runtime Theme를 참고한다. 기존 디자인 정책/과거 기록은 유지한다.