designpaca/packages/skill/references/tokens.md
Yun Chan bd1daa7ec3
All checks were successful
ci / build (push) Successful in 20s
fix(site): 히어로 h1 의 clamp 하한을 h2 기준으로 잡는다
데스크톱만 보고 고쳐서 좁은 화면의 위계가 사라져 있었다.
h1 만 vw 에 비례해 줄고 h2 는 고정 스케일이라 둘이 만난다.

  1440px  65.4 / 33.2 = 1.97 배
  1024px  52.5 / 33.2 = 1.58 배
   390px  35.2 / 33.2 = 1.06 배   ← 사실상 같은 크기

하한을 3rem(48px, h2 의 1.45 배)으로 올려 전 구간을 확보했다.
320 / 390 / 768 / 1024 / 1440 에서 1.45 · 1.45 · 1.63 · 1.87 · 2.12 배.

tokens.md 에 "clamp 의 하한은 같이 변하지 않는 값을 기준으로 정한다" 와
최소·중간·최대 폭 세 곳에서 계산해 확인하라는 절차를 기록했다.
2026-08-21 01:07:47 +09:00

410 lines
17 KiB
Markdown

# tokens — 디자인 토큰과 성능 예산
3단계에서 읽는다. **코드를 쓰기 전에 숫자를 정한다.** 구현하면서 색을 고르면 매번 다른 색이 나온다.
토큰은 취향이 아니라 **계약**이다. 한번 정하면 이후 모든 값은 여기서 나온다. 하드코딩된 값이 하나라도 있으면 수정 요청 한 번에 무너진다.
---
## 1. 타입 스케일
### 비율을 먼저 고른다
| 비율 | 값 | 인상 | 언제 |
|---|---|---|---|
| Minor Third | 1.200 | 차분, 조밀 | 정보 밀도가 높은 페이지, 문서, 대시보드 |
| Major Third | 1.250 | 안정 | 기본값. 대부분의 마케팅 사이트 |
| Perfect Fourth | 1.333 | 또렷한 위계 | 랜딩 페이지, 제품 소개 |
| Golden | 1.618 | 극적 | 포트폴리오, 에디토리얼. 중간 단계가 비어 본문이 외로워진다 |
**한 페이지에 비율은 하나다.** 헤드라인만 다른 비율을 쓰고 싶으면 그건 비율이 아니라 **디스플레이 사이즈를 따로 정의**하는 것이다.
### 실제 값으로 적는다
```css
:root {
/* Perfect Fourth (1.333), 본문 16px 기준 */
--step--1: 0.75rem; /* 12 — 캡션, 레이블 */
--step-0: 1rem; /* 16 — 본문 */
--step-1: 1.333rem; /* 21 — 리드 문단, 소제목 */
--step-2: 1.777rem; /* 28 — h3 */
--step-3: 2.369rem; /* 38 — h2 */
--step-4: 3.157rem; /* 51 — h1 */
--step-5: 4.209rem; /* 67 — 디스플레이 */
}
```
### 반응형은 clamp 로, 미디어쿼리로 하지 마라
```css
--step-4: clamp(2.25rem, 1.5rem + 3.75vw, 3.157rem);
```
`clamp(최소, 기준+vw, 최대)`. 최소값은 **모바일에서 읽히는 크기**, 최대값은 데스크톱 기준. 중간이 매끄럽게 이어져 중간 뷰포트에서 깨지지 않는다.
### 함께 정해야 하는 것
| 토큰 | 규칙 |
|---|---|
| `--leading-tight` | 1.1~1.2 — 디스플레이/헤드라인 |
| `--leading-normal` | 1.5~1.6 — 본문 (라틴) / **1.6~1.8 (한글)** |
| `--measure` | 한 줄 길이. 라틴 60~75자, **한글 25~40자** |
| 자간 | 큰 글자에만 음수(`-0.02em` 정도). **한글에는 음수 자간 금지** |
**폰트는 최대 2종**(+ 코드용 모노 1). 세 번째 텍스트 폰트를 넣고 싶다면 위계를 웨이트로 못 만들고 있다는 신호다.
> **어떤 폰트를 어떻게 고르고 싣는지는 `typography.md` 가 정본이다.** 여기서는 스케일 값만 정한다.
> 로딩 전략, 폴백 메트릭 보정, OpenType 기능, 한글 서브셋, 라이선스 확인이 그 문서에 있다.
---
## 2. 색
### 역할로 정의한다. 팔레트로 정의하지 마라
"파랑 5단계"가 아니라 **무엇에 쓰이는 색인지**로 정의한다.
```css
:root {
--surface: /* 페이지 바탕 */
--surface-raised: /* 카드·패널 (바탕과 구분되되 튀지 않게) */
--ink: /* 본문 텍스트 */
--ink-muted: /* 보조 텍스트 — 대비 4.5:1 유지 */
--line: /* 경계선 */
--accent: /* 강조 — 페이지당 하나 */
--accent-ink: /* 강조 위에 올라가는 글자색 */
}
```
### 규칙
1. **강조색은 하나다.** 두 개가 필요하다고 느끼면 위계 설계가 실패한 것이다. 예외: 상태색(성공/경고/오류)은 강조색이 아니라 기능색이다
2. **채도가 높은 색은 면적을 좁게.** 넓은 면적에 쓰면 눈이 피로하고 싸구려로 보인다
3. **중성색도 색이다.** 순수 회색(`#808080`) 대신 강조색 쪽으로 약간 기운 중성색을 쓰면 화면 전체가 하나로 묶인다
4. **대비를 측정해라.** 본문 4.5:1, 큰 글자 3:1. 눈으로 판단하지 마라
### 다크 모드
**다크가 슬롭인 게 아니라 고르지 않은 다크가 슬롭이다.** (`antipatterns.md` §1 — bun.sh 는 다크인데도 슬롭 항목을 거의 전부 회피한다)
다크를 기본으로 하려면 **근거를 한 줄로 대라.** 정당화되는 이유의 목록은 `presets/dark-instrument.md` 에 있다(야간 운영 환경, 밝은 데이터 시각화의 대비, 제품 자체가 어두움). 댈 수 없으면 라이트로 간다.
어느 쪽을 기본으로 하든 **두 테마를 동등하게 정의한다.** 다크만 만들고 라이트를 빼는 것은 사용자 선택권을 뺏는 것이다.
지원할 때는 색을 뒤집는 게 아니라 **역할별로 다시 정의**한다. 다크에서 순수 검정(`#000`)은 대비가 너무 세서 눈이 아프고, 순수 흰색 텍스트도 마찬가지다.
```css
:root { --surface: #fbfaf8; --ink: #1a1917; }
@media (prefers-color-scheme: dark) {
:root { --surface: #14130f; --ink: #e8e4dc; }
}
```
---
## 3. 간격
### 하나의 리듬에서 파생시킨다
```css
:root {
--space-1: 0.25rem; /* 4 */
--space-2: 0.5rem; /* 8 */
--space-3: 1rem; /* 16 */
--space-4: 1.5rem; /* 24 */
--space-5: 2.5rem; /* 40 */
--space-6: 4rem; /* 64 */
--space-7: 6rem; /* 96 */
--space-8: 10rem; /* 160 — 섹션 간격 */
}
```
**중간 값을 즉석에서 만들지 마라.** `--space-4``--space-5` 사이가 필요하다면 스케일이 잘못된 것이다.
### 여백이 위계를 만든다
- 관련 있는 것끼리는 **가깝게**, 다른 그룹과는 **확실히 멀게**. 애매한 중간 간격이 가장 나쁘다
- 섹션 간격은 **본문 간격의 4배 이상**. 좁으면 페이지가 뭉개진다
- 요소를 정렬할 때 **간격이 아니라 정렬선**을 먼저 맞춰라
### h1 이 h2 보다 한 단계만 크면 위계가 아니다
히어로 제목을 줄이다가 실측에서 이렇게 됐다.
| | 크기 | |
|---|---|---|
| h1 (히어로) | 41.5px | `--step-4` |
| h2 (섹션) | 33.2px | `--step-3` |
**8px 차이.** 스케일상으로는 한 단계 위지만 화면에서는 같은 크기로 읽힌다.
페이지에서 가장 중요한 문장이 아홉 개의 섹션 제목과 구별되지 않았다.
원인은 과잉 교정이었다. 처음에 `--display`(1440px 에서 112px)를 썼다가
배경 그래픽과 싸워서 줄였는데, 줄이다가 h2 와 붙는 데까지 왔다.
**한 극단에서 도망치다 반대쪽 극단에 도착한 것이다.**
기준: **넓은 화면에서 히어로 h1 은 섹션 h2 의 2 배 안팎.** 그 아래면 위계가 없고,
그 위는 배경·여백과 싸우기 시작한다.
### clamp 의 하한은 h2 를 기준으로 정한다
여기서 한 번 더 틀렸다. 데스크톱만 보고 고쳤더니 이렇게 됐다.
```css
.hero-title { font-size: clamp(2.2rem, 1.3rem + 3.1vw, 4.4rem); }
```
| 화면 | h1 | h2 | 비율 |
|---|---|---|---|
| 1440px | 65.4px | 33.2px | 1.97 배 |
| 1024px | 52.5px | 33.2px | 1.58 배 |
| 390px | **35.2px** | 33.2px | **1.06 배** |
**h1 만 `vw` 에 비례해 줄고 h2 는 고정이라, 좁아질수록 둘이 만난다.**
데스크톱에서 고친 위계가 모바일에서 그대로 사라진다.
하한을 h2 의 1.4 배 이상으로 잡으면 전 구간이 유지된다.
```css
/* 하한 3rem = 48px = h2(33.2px)의 1.45 배 */
.hero-title { font-size: clamp(3rem, 1.9rem + 3.1vw, 4.4rem); }
```
| 화면 | 320 | 390 | 768 | 1024 | 1440 |
|---|---|---|---|---|---|
| 비율 | 1.45 | 1.45 | 1.63 | 1.87 | 2.12 |
좁은 화면에서 2 배를 고집할 필요는 없다 — 폭이 좁으면 큰 글자가 줄만 늘린다.
**1.4 배가 하한, 2 배 안팎이 상한**이고 그 사이를 `vw` 가 잇는다.
> 확인법: 한 화면 폭에서만 재지 마라. `clamp` 를 쓴 값은 **최소 폭·중간 폭·최대 폭
> 세 곳에서 계산해 보고**, 같이 변하지 않는 값(고정 스케일의 h2 등)과의 비율을 봐라.
> 그리고 눈으로 비교하지 말고 크기를 재라 — 배경이 화려하면 큰 글자도 작아 보인다.
### 제목 다음 간격은 전역에서 한 번 정한다
`h2 { margin: 0 }` 리셋과 `p { margin: 0 0 X }` 를 같이 쓰면 **제목과 본문이 붙는다.**
위쪽 여백이 양쪽 다 0 이기 때문이다. 눈에 띄게 깨져 보이지만, 섹션을 하나씩 만들다 보면
어떤 섹션에는 `.mat-lead { margin-block: var(--space-4) ... }` 처럼 손으로 붙게 되고
어떤 섹션에는 안 붙는다. 실측에서 한 페이지 안에 0px 인 섹션 둘과 24px 인 섹션 셋이
공존했다. **간격이 컴포넌트마다 다르면 그건 SSOT 가 아니다.**
```css
p { margin: 0 0 var(--space-3); }
/* 제목 다음에 오는 것은 반드시 떨어진다 — 여기 한 곳에서만 정한다 */
h1 + *,
h2 + *,
h3 + *,
.head + * { margin-top: var(--space-4); }
```
세 가지 함정이 있다.
1. **`:where()` 로 감싸면 특이도가 0 이 되어 위의 `p` 규칙에 진다.**
`:where(h2) + :where(.lead)` 는 0-0-0, `p` 는 0-0-1 이다. 아무 효과 없이 조용히 무시된다.
특이도를 갖추고, 캐스케이드에서 `p` **뒤에** 둬라.
2. **제목을 `<div class="head">` 로 감싼 섹션에서는 인접 형제가 끊긴다.**
그 섹션만 혼자 0px 이 된다. 제목 묶음 클래스도 같은 규칙에 넣어라.
3. **섹션이 각자 `margin-top` 을 또 붙이면 전역이 무력해진다.**
위쪽은 전역이, 아래쪽만 섹션이 정한다 — `margin-block: A B` 가 아니라 `margin-bottom: B`.
### 반복되는 리듬은 값이 아니라 토큰 하나로
같은 리듬을 세 곳에서 쓰면 세 곳이 따로 논다. 실측 사례 — 타임라인에서
정거장 패딩은 `--space-5`, 마커 원의 `top``calc(var(--space-4) + 0.55em)`,
연결선의 `top/bottom``--space-4` 로 각자 적혀 있었다. 패딩만 한 단계 올리자
**원이 제목보다 24px 위에 떠 버렸다.**
```css
.spine {
--stop-pad: var(--space-5); /* 리듬의 출처는 한 곳 */
}
.spine::before { top: var(--stop-pad); bottom: var(--stop-pad); }
.stop { padding-block: var(--stop-pad); }
.stop::before { top: calc(var(--stop-pad) + 0.55em); }
```
규칙: **같은 값을 두 번 이상 적게 되면 그 순간 지역 토큰으로 올려라.**
---
## 3-b. 형태 (radius·선)
하드 게이트 #3 이 검사하는 대상이다. **정의하지 않으면 검사할 수 없다.**
```css
:root {
--radius-sm: 2px; /* 입력, 배지 */
--radius-md: 8px; /* 카드, 패널 */
--radius-pill: 999px;
--line-width: 1px;
}
```
**서로 다른 radius 값은 3종 미만으로.** 어휘가 많을수록 우연히 결정된 것으로 보인다.
1종만 쓰는 것도 정당한 선택이다 — `swiss-minimal`·`anti-grid` 에서는 오히려 그쪽이 맞다.
---
## 4. 모션 토큰
```css
:root {
--dur-instant: 100ms; /* 상태 변화 — 호버, 포커스 */
--dur-quick: 200ms; /* 작은 요소 등장/퇴장 */
--dur-normal: 350ms; /* 패널, 모달 */
--dur-slow: 600ms; /* 페이지 전환, 큰 이동 */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* 들어오는 것 — 기본값 */
--ease-in: cubic-bezier(0.64, 0, 0.78, 0); /* 나가는 것 */
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1); /* 위치 이동 */
}
```
상세는 `references/motion.md`. 여기서는 **값을 고정**하는 것이 목적이다. 컴포넌트마다 다른 duration 을 쓰면 페이지가 불안해 보인다.
---
## 웨이트와 컨트롤 — 자주 빠지는 두 축
타입 스케일·색·간격은 대부분 토큰으로 잡는다. **웨이트와 컨트롤 치수는 거의 안 잡는다.**
그래서 SSOT 가 거기서 먼저 깨진다.
### 웨이트는 세 개다
```css
--weight-body: 400;
--weight-medium: 500;
--weight-strong: 600;
```
컴포넌트마다 `font-weight: 560` 같은 값을 직접 쓰기 시작하면 페이지에 웨이트가 여섯 종이 된다.
실측 사례: designpaca 소개 페이지가 `400 / 500 / 560 / 600 / 620 / 660` 을 동시에 쓰고 있었고,
그중 셋은 토큰에 없는 값이었다.
가변폰트는 660 같은 중간값도 그려준다. 그래서 더 위험하다 — **표준 축(400/500/600/700)을
벗어나면 힌팅이 흐려지고, 폴백 폰트에서는 아예 다른 굵기로 떨어진다.**
**웨이트로 위계를 만들려 들지 마라.** R1 실측(zed.dev 모바일)에서 `font-weight: 400` 이 832회,
나머지 전부 합쳐 12회였다. 위계는 크기와 색이 만든다.
### 버튼처럼 생긴 것은 전부 같은 치수를 쓴다
```css
--control-h: 2.75rem; /* 44px — 터치 타깃 */
--control-h-sm: 2.25rem; /* 36px — 헤더 등 조밀한 자리 */
--control-pad-x: var(--space-3);
```
컴포넌트마다 높이와 패딩을 다시 정하면 그때부터 SSOT 가 아니다.
실측 사례: 같은 페이지의 두 버튼이 높이 36 vs 44, 패딩 16 vs 8, 테두리 1px vs 0,
웨이트 400 vs 500 이었다. 규칙이 없으니 전부 달랐다.
반대로 R1 의 두 버튼은 `h36 · pad-x10 · r4 · w400 · 14px`**픽셀 단위까지 같았다.**
### 떠 있는 헤더는 높이를 토큰으로 내놔야 한다
```css
--header-h: calc(var(--space-3) + var(--space-2) * 2 + var(--control-h-sm));
```
`position: fixed` 헤더는 문서 흐름에서 빠져 있어서, 첫 섹션의 상단 여백과 앵커 목적지
(`scroll-margin-top`)가 그 높이를 알아야 한다. 값을 각자 손으로 맞춰두면
**간격 토큰을 `clamp()` 로 바꾸는 순간 관계가 끊어져 제목이 헤더 뒤로 들어간다.**
값이 아니라 관계를 토큰으로 둬라.
### 큰 간격은 화면에 반응해야 한다
작은 값(4~24px)은 고정, 큰 값만 `clamp()`.
```css
--space-6: clamp(2.5rem, 1.6rem + 2.6vw, 4rem);
--space-7: clamp(3.5rem, 1.9rem + 4.6vw, 7rem);
--gutter: clamp(2rem, 1.4rem + 1.8vw, 2.5rem);
```
고정으로 두면 모바일에서 섹션 상하 여백 112px 이 그대로 들어가 **화면 높이의 26% 를 여백이 먹는다.**
좌우 여백도 마찬가지다 — R1 모바일 실측은 32px 이었다.
---
## 5. 성능 예산
**여기서 정한다. 구현 후에 재면 이미 늦었다.**
**이 표가 성능 예산의 유일한 원본이다.** SKILL.md 도 `preflight.md` 도 여기를 가리킨다. 다른 문서에 예산 표를 만들면 값이 갈라지고, 갈라진 순간 아무도 어느 쪽이 맞는지 모른다.
| 항목 | 기본 | 데모/포트폴리오 | 5단계에서 |
|---|---|---|---|
| 히어로까지 JS (gzip) | 150KB | 400KB | 측정 |
| WebGL/3D 추가분 | +200KB | +600KB | 측정 |
| 총 전송량 (첫 화면) | 1MB | 2MB | 측정 |
| 첫 인터랙션 (모바일 4G) | 3초 | 5초 | 측정 |
| LCP | 2.5초 | 3.5초 | 측정 |
| CLS | 0.1 | 0.1 | 측정 |
| 폰트 **패밀리** | 2개 이하 | 3개 | 개수 확인 |
| 폰트 전송량(첫 화면) | 100KB | 200KB | 측정 |
| 애니메이션 속성 | transform/opacity 만 | 동일 (타협 없음) | grep |
예산을 올렸다면 **올렸다는 사실과 이유를 명시**해라. 조용히 넘기는 것이 가장 나쁘다.
> **폰트는 파일 개수가 아니라 패밀리 수와 전송량으로 센다.** 한글 웹폰트를 유니코드 범위별로
> 수십 개 파일로 쪼개는 것은 **올바른 최적화**다(Pretendard `dynamic-subset` 은 14개 이상).
> 브라우저는 페이지에 실제로 쓰인 글자 범위만 받는다. 파일 개수를 줄이라고 요구하면
> 한글 프로젝트를 단일 대용량 파일이라는 잘못된 방향으로 몬다.
### LCP·CLS 를 어떻게 재나
`preflight.md` 가 이 값을 요구한다. 재는 법이 없으면 "미측정"으로 남고, 미측정은 통과가 아니다.
```html
<!-- 페이지에 임시로 넣고 콘솔을 본다. 측정 후 반드시 제거한다 -->
<script>
new PerformanceObserver((l) => {
const e = l.getEntries().at(-1);
console.log('LCP', Math.round(e.startTime), e.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
let cls = 0;
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (!e.hadRecentInput) cls += e.value;
console.log('CLS', cls.toFixed(3));
}).observe({ type: 'layout-shift', buffered: true });
</script>
```
**프로덕션 빌드에 돌려라.** 개발 서버는 번들이 다르고 HMR 스크립트가 섞여 값이 의미 없다.
Lighthouse 를 쓸 수 있으면 그쪽이 더 정확하다 — 단 모바일 프로파일로.
### 예산을 지키는 기본 수단
- 폰트: 상세는 `typography.md` §3~§4. 요약하면 `font-display` 를 의식적으로 고르고, preload 는 첫 화면에 실제로 쓰는 파일만, 폴백 메트릭을 보정하거나 `optional` 을 쓴다
- 이미지: 실제 표시 크기의 2배까지만, AVIF/WebP, 첫 화면 밖은 `loading="lazy"`
- JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import
- **측정하지 않은 최적화는 하지 마라.** 대신 예산을 넘겼는지는 반드시 측정해라
---
## 6. 산출물 형식
3단계를 마치면 아래가 실제 값으로 채워져 있어야 한다. 이것이 4단계의 입력이다.
```css
:root {
/* 타입 — 비율 ____ , 디스플레이는 스케일 밖 별도 정의 여부 ____ */
--step--1 ~ --step-5, --leading-*, --measure
/* 색 — 역할별. 다크 테마도 함께 */
--surface, --surface-raised, --ink, --ink-muted, --line, --accent, --accent-ink
/* 간격 */
--space-1 ~ --space-8
/* 형태 — 하드 게이트 #3 이 이걸 검사한다 */
--radius-*, --line-width
/* 모션 */
--dur-*, --ease-*
}
```
그리고 한 줄로: **성능 예산 = JS ___KB / 첫 인터랙션 ___초 / 폰트 패밀리 ___개**
**정의하지 않은 토큰은 5단계에서 검사할 수 없다.** 쓰지 않을 항목은 "쓰지 않음"이라고 적어라 — 빈칸과 "없음"은 다르다.
> 근거: research/references/03-trends-2026.md, 04-ai-slop-signatures.md (조사일 2026-08-20)