디자인 시스템과 토큰
본 문서는 UI 킷의 구성 방식, 디자인 토큰의 원본 위치, 그리고 shared/ui의 경계를 정의합니다.
1. 구성
UI는 두 층으로 구성합니다. 각 층의 소유권이 다릅니다.
| 층 | 패키지 | 소유 | 담당 |
|---|---|---|---|
| brain | @spartan-ng/brain (npm) |
외부 | 접근성, 포커스 관리, 키보드 조작, ARIA, 오버레이 |
| helm | shared/ui/ (소스) |
우리 | 마크업, 클래스, 시각적 표현 |
| 기반 | @angular/cdk (npm) |
외부 | brain이 사용하는 프리미티브 |
@spartan-ng/brain은 @angular/cdk를 peer로 요구합니다. brain과 CDK는 대체 관계가 아니라 brain이 CDK 위에 세워진 층입니다.
helm 컴포넌트는 npm 패키지가 아니라 CLI로 소스를 복사해 옵니다.
# --stylesEntryPoint 를 명시하는 이유는 개발-환경.md 4절을 참조합니다
ng g @spartan-ng/cli:init --theme=zinc --stylesEntryPoint=src/app/styles.css
ng g @spartan-ng/cli:ui # 컴포넌트 선택 후 소스 복사
ng g @spartan-ng/cli:ui-theme # 테마 변수 생성init은 @angular/cdk·@spartan-ng/brain·tailwind-merge·tw-animate-css를 설치하고 전역 스타일에 Tailwind 계층, Spartan 프리셋 임포트, 테마 변수, @layer base 기본 규칙을 기록합니다. angular.json은 수정하지 않습니다.
복사된 소스는 shared/ui/ 아래에 배치합니다. 배치 경로와 스타일은 components.json이 원본입니다.
{ "componentsPath": "src/shared/ui", "style": "vega", "importAlias": "@/shared/ui" }style 은 helm 의 마크업과 클래스 형태를 정하는 축이며 색 팔레트와 별개입니다. nova·vega·lyra·maia·mira·luma 중 하나이고 기본값은 vega 입니다. 컴포넌트를 추가하기 전에 확정합니다. 나중에 바꾸면 이미 복사된 사본을 재생성해야 하고, 2절에 따라 자유롭게 수정한 내용이 덮입니다.
1.1 helm 사본의 구조와 공개 API
생성기는 컴포넌트마다 다음 구조를 만듭니다.
src/shared/ui/
└── button/
└── src/
├── index.ts
└── lib/
└── hlm-button.tssrc/index.ts가 컴포넌트 진입점이며 tsconfig가 이 경로를 @/shared/ui/button으로 매핑합니다.
사용처는 컴포넌트별 경로를 임포트합니다. @/shared/ui/button 형태이며 세그먼트 배럴을 두지 않습니다.
import { HlmButton } from '@/shared/ui/button';
import { HlmSheet } from '@/shared/ui/sheet';배럴을 두지 않는 이유는 @defer 때문입니다. 같은 배럴에서 즉시 쓰는 심볼과 지연 대상 심볼을 함께 가져오면 배럴 모듈이 정적 의존으로 확정되어 지연 청크가 만들어지지 않습니다. 본 저장소에서 배럴을 제거한 결과 초기 번들이 544kB 에서 275kB 로 줄었습니다.
이 구조는 FSD 규칙 셋과 부딪히며, 해소 방식은 다음과 같습니다.
| 충돌 | 해소 |
|---|---|
컴포넌트 폴더에 index.ts 가 없음 (fsd/public-api) |
steiger.config.ts 에서 해당 경로만 해제합니다 |
lib 폴더명이 세그먼트명과 겹침 (fsd/no-reserved-folder-names) |
같은 방식으로 해제합니다 |
컴포넌트별 경로를 우회로 판정 (fsd/no-public-api-sidestep) |
규칙을 해제하고 ESLint no-restricted-imports 로 대체합니다 |
앞의 둘을 해제하는 이유는 구조를 손으로 평탄화하면 컴포넌트를 추가하거나 재생성할 때마다 같은 작업을 반복하게 되기 때문입니다.
세 번째는 성격이 다릅니다. @/shared/ui/button 은 tsconfig 가 button/src/index.ts 로 매핑하는 그 컴포넌트의 공개 API 이며 내부 파일이 아닙니다. Steiger 가 경로 문자열만 보고 판정하는데 규칙 단위로 예외를 둘 수 없어 전역 해제했고, 슬라이스 내부 파일 직접 임포트 금지는 ESLint 패턴이 대신합니다. 대체 규칙의 정의는 eslint.config.js 가 원본입니다.
2. helm 수정 정책
복사해 온 helm 컴포넌트는 자유롭게 수정합니다. 마크업 구조, 클래스, 입출력 API 모두 대상입니다.
이 정책의 근거는 적응형 UI입니다. 같은 논리적 컴포넌트가 상호작용 특성에 따라 다른 DOM과 다른 상호작용 모델을 가져야 하므로, 토큰과 클래스만 바꾸는 방식으로는 구현할 수 없습니다.
대가는 업스트림의 시각적 개선이 자동으로 오지 않는다는 점입니다. Spartan이 helm의 마크업을 개선해도 우리 사본에는 반영되지 않습니다.
API 수준의 갱신은 도구가 있습니다. Spartan 업그레이드 후 ng g @spartan-ng/cli:healthcheck --autoFix를 실행하면 폐기된 API를 조정합니다. 시각적 개선만 수동 대상입니다.
이 대가가 감당 가능한 이유는 층이 분리되어 있기 때문입니다. 접근성과 동작은 brain이 소유하며 helm은 시각 층입니다. helm을 고쳐도 포커스 트랩, 키보드 조작, ARIA 속성 연결은 brain이 계속 담당합니다. 사본이 낡아서 잃는 것은 시각적 개선이지 접근성이 아닙니다.
| 구분 | 준수 지침 (Do) | 금지 지침 (Don't) |
|---|---|---|
| helm 수정 | 마크업과 클래스를 목적에 맞게 고칩니다 | brain 디렉티브 연결을 임의로 제거합니다 |
| brain | 제공하는 디렉티브를 그대로 사용합니다 | node_modules를 패치합니다 |
brain 디렉티브를 떼어내면 접근성 보장이 함께 사라집니다. helm을 아무리 고쳐도 brain과의 연결은 유지합니다.
3. 디자인 토큰
3.1 원본
토큰의 원본은 src/app/styles.css의 CSS 변수입니다. OKLCH 색상 공간을 사용하며 :root.dark 선택자로 다크 모드 값을 재정의합니다.
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--radius: 0.625rem;
}
:root.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
}선택자를 .dark로 넓히지 않습니다. :root.dark는 문서 루트에만 적용되지만 .dark는 중첩된 요소에도 걸리므로, 부분 영역에 클래스를 붙였을 때 의도하지 않은 재정의가 발생합니다. 생성기가 만드는 형태도 :root.dark입니다.
토큰 값을 본 문서에 옮겨 적지 않습니다. 위 예시는 형태만 보이기 위한 것이며 실제 값의 원본은 styles.css입니다.
전역 스타일시트는 Tailwind 계층과 함께 Spartan 프리셋을 임포트합니다. 이 프리셋이 애니메이션 유틸리티와 CDK 오버레이 스타일을 함께 제공하므로 누락하면 다이얼로그와 팝오버가 깨집니다.
@import '@spartan-ng/brain/hlm-tailwind-preset.css';3.2 의미 토큰 목록
| 짝을 이루는 토큰 | 용도 |
|---|---|
background / foreground |
화면 기본 |
card / card-foreground |
카드 표면 |
popover / popover-foreground |
오버레이 표면 |
primary / primary-foreground |
주 동작 |
secondary / secondary-foreground |
보조 동작 |
muted / muted-foreground |
약화된 영역 |
accent / accent-foreground |
강조 |
destructive / destructive-foreground |
파괴적 동작 |
짝이 없는 단독 토큰은 border, input, ring이며 --radius와 sidebar* 계열이 추가로 존재합니다. sidebar*는 레이아웃의 앱셸 골격에서 사용합니다.
3.3 타이포그래피 토큰
| 토큰 | 값 | 용도 |
|---|---|---|
--font-sans |
Pretendard Variable | 본문 전체 |
--font-mono |
JetBrains Mono Variable → Pretendard Variable | 코드, 키 입력 표시 |
본문에 영문 전용 폰트를 추가로 지정하는 것을 금지합니다. Pretendard 가 라틴을 이미 포함하며 한글과 어울리도록 조정되어 있으므로, 다른 영문 폰트를 앞에 두면 그 조정이 무효가 됩니다. 선택 근거와 로딩 방식은 ADR-0014가 원본입니다.
--font-mono 의 두 번째 항목이 Pretendard 인 것은 의도입니다. JetBrains Mono 가 한글을 담지 않으므로 한글은 본문과 같은 글꼴로 떨어집니다. 이 구간에서 고정폭이 깨지므로 코드 블록 안에서 공백으로 열을 맞추지 않습니다. 정렬이 의미를 갖는 내용은 표로 표현합니다.
크기와 두께는 토큰을 두지 않고 Tailwind 의 유틸리티를 그대로 사용합니다. 척도가 이미 정의되어 있어 별도 토큰을 두면 두 벌이 됩니다.
3.4 토큰 사용 규칙
<!-- 허용 -->
<div class="bg-background text-foreground border-border">
<!-- 금지 -->
<div class="bg-white text-gray-900 border-gray-200">
<div class="bg-[#ffffff]">
<div style="color: #333">임의 색상값의 직접 기재를 금지합니다. Tailwind의 기본 팔레트(gray-200, blue-500)도 금지 대상입니다. 토큰을 거치지 않으면 다크 모드에서 대비가 깨지고, 브랜드 색상 변경이 전역 치환 작업이 됩니다.
간격과 반경도 같습니다. --radius 계열 토큰을 사용하고 rounded-[7px] 형태의 임의값을 쓰지 않습니다.
예외는 문서 본문의 코드 블록과 다이어그램 두 가지입니다. 코드 블록은 shiki 테마의 색이, 다이어그램은 mermaid 테마의 색이 각각 인라인으로 들어갑니다. 두 경우 모두 브랜드 색과 무관한 식별용 팔레트이고, 라이트와 다크 값을 모두 갖추어 모드에 따라 전환되며, 변경 지점이 테마 지정 한 곳입니다. 위 두 가지 금지 근거가 성립하지 않아 예외로 둡니다. 근거는 ADR-0015가 원본입니다.
이 예외는 문서 본문에만 적용됩니다. 화면 컴포넌트에는 적용되지 않습니다.
3.5 종이 질감
화면 전체에 옅은 노이즈를 덮어 색면이 균일하게 비어 보이지 않게 하는 선택 항목입니다. body에 paper-grain 클래스를 붙이면 켜집니다. 기본값은 꺼짐입니다.
| 화면 성격 | 판단 |
|---|---|
| 읽는 화면 (문서, 아티클, 랜딩) | 적용합니다. 색면이 넓고 텍스트가 커서 질감이 어울립니다 |
| 업무 화면 (표, 폼, 목록) | 적용하지 않습니다. 작은 글자의 가독성이 떨어집니다 |
| 데이터 그리드·가상 스크롤이 있는 화면 | 적용하지 않습니다. 화면 전체를 덮는 고정 레이어가 부담이 됩니다 |
주의
질감을 켜면 대비 여유를 더 확보해야 합니다
노이즈가 전경과 배경 양쪽에 얹혀 대비를 낮춥니다. 실측으로 라이트에서 0.24, 다크에서 0.63 하락합니다. 4.5:1을 겨우 넘는 색은 질감을 켠 화면에서 기준 아래로 내려갑니다. 노이즈는 픽셀마다 흔들리므로 국소적으로는 더 낮은 지점도 생깁니다.
고정 레이어가 상주하므로 QHD 기준 약 14MB를 점유합니다. 상단 바가 backdrop-filter를 쓰는 화면에서는 합성이 두 겹이 되므로, 저사양 환경에서 스크롤이 걸리면 이 클래스를 떼는 것이 첫 번째 대응입니다.
3.6 토큰 추가
기존 토큰으로 표현할 수 없는 값이 필요하면 토큰을 먼저 추가하고 사용합니다. 컴포넌트에 임의값을 넣은 뒤 나중에 정리하는 순서는 금지합니다.
세 가지를 함께 정의합니다.
:root { --warning: oklch(0.84 0.16 84); --warning-foreground: oklch(0.28 0.07 46); }
:root.dark { --warning: oklch(0.41 0.11 46); --warning-foreground: oklch(0.99 0.02 95); }
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}@theme inline 등록을 빠뜨리면 bg-warning 같은 유틸리티 클래스가 생성되지 않아 변수만 정의된 채 쓸 수 없습니다.
-foreground 짝이 없는 배경 토큰은 그 위에 어떤 색 텍스트를 올릴지 판단이 갈리므로 대비 문제를 만듭니다.
3.7 치수와 밀도
컨트롤의 크기는 포인터의 정밀도에 따라 갈립니다. helm 이 들고 오는 기본값은 마우스를 전제한 값이므로 그대로 두면 터치 환경에서 전부 기준 미달입니다.
판정 축
폭이 아니라 pointer 로 판정합니다. 근거는 적응형 UI 2절이며, 좁게 줄인 데스크탑 창과 태블릿 가로가 폭으로는 반대로 판정되기 때문입니다.
치수 조정에는 런타임 판정이 필요 없습니다. pointer-coarse: 변형이 미디어 쿼리로 컴파일되므로 CSS 만으로 해결되며, 정적 생성 경로에서도 그대로 동작합니다. injectInteractionMode() 는 컴포넌트를 교체할 때 쓰는 것이고 크기를 바꿀 때 쓰는 것이 아닙니다.
기준값
| 항목 | pointer: fine |
pointer: coarse |
|---|---|---|
| 상호작용 컨트롤의 최소 크기 | helm 기본값 | 44 × 44 |
| 글자를 입력하는 컨트롤의 글자 크기 | text-sm |
text-base(16px) 이상 |
두 번째 값은 미관이 아니라 동작 문제입니다. iOS Safari 는 글자가 16px 미만인 입력에 포커스가 가면 화면을 확대하며, 사용자가 그 배율을 직접 되돌려야 합니다.
44 × 44 의 출처는 적응형 UI 7절이며 본 절은 그 값을 컨트롤 치수로 옮긴 것입니다.
적용 위치
크기 변형마다 값을 흩지 않고 컴포넌트의 기본 클래스에 바닥을 깝니다. sm 과 icon-sm 에만 붙이면 나중에 추가되는 변형이 조용히 기준을 벗어납니다.
// 크기 변형과 무관하게 터치 환경의 바닥을 보장합니다.
const TOUCH_TARGET = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';밀도가 촘촘해야 하는 자리, 예컨대 표의 행 안 조작 버튼처럼 44px 을 시각적으로 확보할 수 없는 경우에는 보이는 크기를 유지하고 누를 수 있는 영역만 넓힙니다. 적응형 UI 7절의 "시각적 크기가 작아도"는 이 예외를 가리킵니다. 기본은 시각 치수를 함께 키우는 것이며 예외를 기본으로 쓰지 않습니다.
4. 스타일 작성 규칙
| 규칙 | 내용 |
|---|---|
| 내장 변형 우선 | 컴포넌트가 노출하는 variant·size 입력을 사용합니다. 클래스로 다시 칠하지 않습니다 |
helm 에 거는 class는 레이아웃 전용 |
배치와 여백(flex, grid, gap, margin, width)에만 씁니다. 색·타이포그래피·내부 여백을 덮어쓰려면 복사한 helm 파일이나 CSS 변수를 고칩니다 |
간격은 gap-* |
space-x-*·space-y-*를 쓰지 않습니다 |
동일 치수는 size-* |
w-4 h-4 대신 size-4를 씁니다 |
| z-index 직접 관리 금지 | 다이얼로그·시트·팝오버·툴팁·메뉴는 CDK 오버레이가 쌓임 순서를 관리합니다 |
class로 컴포넌트 내부 표현을 덮어쓰면 같은 컴포넌트가 사용처마다 다르게 보이고, 나중에 helm 파일을 고칠 때 어느 사용처가 영향을 받는지 알 수 없게 됩니다.
이 제한은 helm 컴포넌트에 클래스를 걸 때 적용됩니다. <a>·<ul> 같은 순수 HTML 요소를 Tailwind 클래스로 꾸미는 것은 대상이 아니며, 그 자리에는 덮어쓸 컴포넌트 표현이 존재하지 않습니다. 다만 같은 조합이 세 곳 이상에서 반복되면 shared/ui로 승격할 시점입니다.
5. shared/ui의 경계
| 들어가는 것 | 들어가지 않는 것 |
|---|---|
| 버튼, 입력, 선택, 다이얼로그, 표, 배지 | 특정 도메인의 카드나 행 |
| 레이아웃 프리미티브 (스택, 그리드) | 화면 전용 조합 |
| 로딩 표시, 빈 상태 표시 | 업무 규칙이 담긴 표시 로직 |
| 아이콘 래퍼 | 서버 조회 |
shared/ui의 컴포넌트는 도메인 이름을 갖지 않습니다. AssessmentCard는 shared/ui에 들어갈 수 없습니다. Card는 들어갈 수 있고, 평가표 카드는 Card를 조합해 pages에서 만듭니다.
판정 기준은 하나입니다. 다른 프로젝트에 그대로 옮겨도 의미가 통하는가. 통하면 shared/ui이고, 통하지 않으면 도메인 지식이 섞인 것입니다.
6. 아이콘
아이콘은 shared/ui/icon/에 래퍼를 두고 그것을 통해서만 사용합니다. SVG를 템플릿에 직접 붙여 넣는 것을 금지합니다.
래퍼를 두는 이유는 크기·색상 토큰 적용을 한 곳에서 보장하고, 아이콘 세트를 교체할 때 사용처를 수정하지 않기 위함입니다.
장식용 아이콘은 aria-hidden="true"를 갖고, 아이콘만으로 동작을 표현하는 버튼은 접근 가능한 이름을 갖습니다. 상세는 접근성에 있습니다.
7. 다크 모드
.dark 클래스를 문서 루트에 부여하는 방식으로 전환합니다. 클래스 부여 로직은 app 계층이 소유합니다.
사용자 선택을 저장한다면 localStorage를 사용하며, 이는 브라우저 API이므로 렌더링 전략 2절의 격리 규칙을 따릅니다. 정적 생성 경로에서는 서버가 사용자 설정을 알 수 없어 첫 렌더가 어긋날 수 있으므로, 시스템 설정(prefers-color-scheme)을 CSS로 우선 반영하고 사용자 선택은 하이드레이션 후 적용합니다.
8. 금지 사항
| 금지 | 사유 |
|---|---|
| 임의 색상값 직접 기재 | 다크 모드 대비가 깨지고 변경이 전역 치환이 됩니다 |
| Tailwind 기본 팔레트 사용 | 토큰 체계를 우회합니다 |
| brain 디렉티브 제거 | 접근성 보장이 함께 사라집니다 |
node_modules 패치 |
재설치 시 소실되며 추적되지 않습니다 |
shared/ui에 도메인 이름 컴포넌트 |
다른 프로젝트로 옮길 수 없게 됩니다 |
| SVG를 템플릿에 직접 삽입 | 토큰 적용과 세트 교체가 어려워집니다 |
class로 컴포넌트 내부 색·타이포그래피 덮어쓰기 |
같은 컴포넌트가 사용처마다 다르게 보입니다 |
space-x-* · space-y-* 사용 |
gap-*이 flex·grid와 일관되게 동작합니다 |
| 오버레이의 z-index 직접 지정 | CDK가 관리하는 쌓임 순서와 충돌합니다 |
@theme inline 없이 토큰만 추가 |
유틸리티 클래스가 생성되지 않습니다 |
| 토큰 없이 임의값을 쓰고 나중에 정리 | 정리 시점이 오지 않습니다 |