# tokens — 디자인 토큰과 성능 예산
3단계에서 읽는다. **코드를 쓰기 전에 숫자를 정한다.** 구현하면서 색을 고르면 매번 다른 색이 나온다.
토큰은 취향이 아니라 **계약**이다. 한번 정하면 이후 모든 값은 여기서 나온다. 하드코딩된 값이 하나라도 있으면 수정 요청 한 번에 무너진다.
---
## 이 문서를 읽는 법
| 상황 | 읽을 곳 |
|---|---|
| **3단계에서 토큰 전체를 정한다** | §1~§4 + "웨이트와 컨트롤"(§4 뒤, 번호 없는 절) + §6. §5는 예산을 정할 때 |
| 타입 스케일·행간·measure를 정한다(한글 값의 정본은 [typography.md](typography.md) §6-5) | §1 |
| 역할 색 토큰 | §2(방법은 [color.md](color.md)) |
| 간격 스케일·수직 리듬·제목 간격 | §3 |
| radius·선 | §3-b |
| 모션 토큰 | §4 |
| 웨이트·컨트롤 치수·헤더 높이 | "웨이트와 컨트롤" |
| 성능 예산(INP·LCP·CLS·전송량) | §5 |
| 3단계 산출물 형식(Tailwind·컴포넌트 기반이면 [component-systems.md](component-systems.md) §3·§7-1) | §6 |
---
## 1. 타입 스케일
### 비율을 먼저 고른다
| 비율 | 값 | 인상 | 언제 |
|---|---|---|---|
| Minor Third | 1.200 | 차분, 조밀 | 정보 밀도가 높은 페이지, 문서, 대시보드 |
| Major Third | 1.250 | 안정 | 기본값. 대부분의 마케팅 사이트 |
| Perfect Fourth | 1.333 | 또렷한 위계 | 랜딩 페이지, 제품 소개 |
| Golden | 1.618 | 극적 | 포트폴리오, 에디토리얼. 중간 단계가 비어 본문이 외로워진다 |
본문 scale은 하나의 비율에서 시작하면 관리하기 쉽다. display가 다른 비율이나 별도 크기를 요구할 수 있으므로, 토큰 이름·역할·대표 폭에서의 위계 검증을 함께 기록한다.
### 실제 값으로 적는다
```css
:root {
/* Perfect Fourth (1.333), 본문 16px 기준 */
--step--1: 0.75rem; /* 12 — 라틴 캡션·레이블. 한글 프로젝트는 0.8125rem(13) 이상에서 시작(typography §6-5) */
--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 — 디스플레이 */
}
```
### 유동 크기와 구조 전환을 구분한다
```css
--step-4: clamp(2.25rem, 1.5rem + 3.75vw, 3.157rem);
```
`clamp(최소, 기준+vw, 최대)`는 연속적으로 변해야 하는 값에 유용하다. 최소·최대는 대표 콘텐츠로 확인한다. grid 열, 내비, 작업대처럼 구조가 바뀌는 지점에는 미디어쿼리나 container query를 쓰고, 임의 기기 폭이 아니라 실제 실패 지점에서 정한다.
### 함께 정해야 하는 것
| 토큰 | 규칙 |
|---|---|
| `--leading-tight` | 1.1~1.2부터 검토(라틴), **한글 표제 1.2~1.35** — 디스플레이/헤드라인 |
| `--leading-normal` | 1.5~1.6(라틴), **한글 1.7 시작·범위 1.6~1.8·하한 1.5** — 본문 |
| `--measure` | 한 줄 길이의 시작 범위. 라틴 60~75자, **한글 32~40자(좁은 컬럼 20자대까지)**. 한글은 `em`으로 적고 `ch`로 적지 않는다 |
| 자간 | `em` 단위로만. 큰 라틴 디스플레이에서 작은 음수 자간을 후보로 두고 실제 렌더로 확인. 한글은 본문 0~-0.01em, 큰 표제 -0.02em 시작 + 글리프 충돌 검사 |
**한글 값의 정본은 [typography.md](typography.md) §6-5다.** 위 표는 그 값을 토큰 자리에 옮긴 요약이다. 둘이 다르면 §6-5를 따르고 이 표를 고친다.
행간과 measure 범위는 시작값이다. 한글 서체·문자·폭·실제 문단을 렌더하고 확대 상태까지 확인해 정한다. 폰트 수는 전송량·언어 범위·위계·라이선스를 함께 보고 예산으로 정한다. 두 텍스트 패밀리와 필요 시 모노는 흔한 시작점이지만, 세 번째 패밀리가 무조건 실패라는 뜻은 아니다.
**관찰 후보 — 라틴 측정폭 60자 하한은 고정선이 아니다.** 사이드바·카드처럼 컬럼 자체가 좁을 때는 45~59자 구간도 시작값으로 허용한다. 다른 스킬은 45~75자 범위를 제시하지만, 이 스킬의 60~75자·한글 32~40자 값을 대체하는 것이 아니라 좁은 컬럼에서만 여는 예외다 [SKILL-DESIGN-REVIEW]. 한글 좁은 컬럼의 예외(20자대)도 같은 성격이다.
> **어떤 폰트를 어떻게 고르고 싣는지는 `typography.md` 가 정본이다.** 여기서는 스케일 값만 정한다.
> 로딩 전략, 폴백 메트릭 보정, OpenType 기능, 한글 서브셋, 라이선스 확인이 그 문서에 있다.
---
## 2. 색
### 역할로 정의한다. 팔레트로 정의하지 마라
"파랑 5단계"가 아니라 **무엇에 쓰이는 색인지**로 정의한다.
```css
:root {
--surface: /* 페이지 바탕 */
--surface-raised: /* 카드·패널 (바탕과 구분되되 튀지 않게) */
--ink: /* 본문 텍스트 */
--ink-muted: /* 보조 텍스트 — 대비 4.5:1 유지 */
--line: /* 경계선 */
--accent: /* 강조 역할이 필요할 때의 기본 토큰 */
--accent-ink: /* 강조 위에 올라가는 글자색 */
}
```
> **색 체계의 램프 생성·OKLCH 파생·그라디언트 보간·다크모드 재조정·대비 수정 절차는 [color.md](color.md) 가 정본이다.** 여기서는 역할 토큰 7개만 정한다. 여기 없는 역할이 필요할 때(상태색을 더 세분하거나 채색 액션을 늘릴 때) 확장하는 방법은 color.md §8을 본다.
### 규칙
1. 강조 역할은 한 색에서 시작해 경쟁 여부를 검증한다. 여러 강조 역할이 필요할 수 있으며, 그때는 행동·상태·정보 목적을 역할 토큰으로 구분한다. 수만으로 위계 실패를 판정하지 않는다
2. 색 맥락 관찰은 [visual-design-lineage.md](./visual-design-lineage.md)를 참고한다. 이 스킬에서는 브랜드 강도·정보 위계·접근성의 근거로 색을 고르고, 실제 상태·면적·인접색에서 텍스트 대비와 판독성을 별도로 검증한다
3. **중성색도 색이다.** 순수 회색과 색조를 띤 중성색을 모두 후보로 두고, 표면·브랜드·상태색과의 관계를 비교한다
4. **대비를 측정해라.** 본문 4.5:1, 큰 글자 3:1. 눈으로 판단하지 마라
### 다크 모드
**다크가 슬롭인 게 아니라 고르지 않은 다크가 슬롭이다.** (`antipatterns.md` §1 — bun.sh 는 다크인데도 슬롭 항목을 거의 전부 회피한다)
다크를 기본으로 하려면 **근거를 한 줄로 대라.** 정당화되는 이유의 목록은 `presets/dark-instrument.md` 에 있다(야간 운영 환경, 밝은 데이터 시각화의 대비, 제품 자체가 어두움). 댈 수 없으면 다크를 기본으로 삼지 않는다. 그렇다고 밝은 오프화이트가 자동으로 답이 되는 것은 아니다 — 배경 명도는 프리셋의 범위([presets/README.md](presets/README.md), 중간 명도·유채색 면 포함)에서 브리프 근거로 고른다.
라이트/다크 모두를 지원하기로 한 제품은 역할 토큰을 각각 정의하고 실제 상태를 검증한다. 제품이 한 테마만 제공할 수는 있지만, 사용자 환경·브리프·운영 맥락과 접근성 영향을 명시한다.
지원할 때는 색을 뒤집는 게 아니라 **역할별로 다시 정의**한다. 순수 검정·흰색과 근접한 색은 제품·디스플레이·주변광에 따라 읽기 경험이 다르므로, 대비·눈부심·브랜드 표면을 실제 기기에서 확인한다.
```css
:root { --surface: <밝은 테마 표면>; --ink: <그 위에서 대비를 잰 본문색>; }
@media (prefers-color-scheme: dark) {
:root { --surface: <어두운 테마 표면 — 뒤집지 않고 다시 정한 값>; --ink: <다시 잰 본문색>; }
}
```
값을 자리표시로 둔 것은 의도다. 이 자리에 있던 웜 오프화이트·니어블랙 한 쌍이 쇼케이스 표면색의 대부분이 됐다. 성격이 다른 세 쌍의 예는 [color.md](color.md) §5에 있다.
---
## 3. 간격
### 하나의 리듬에서 파생시킨다
```css
:root {
/* 4px 기반. 토큰 번호 = px ÷ 4 라서 나중에 값을 끼워 넣어도 번호가 밀리지 않는다 */
--space-1: 0.25rem; /* 4 */
--space-2: 0.5rem; /* 8 */
--space-3: 0.75rem; /* 12 — 컨트롤 내부 gap, 아이콘-라벨 */
--space-4: 1rem; /* 16 */
--space-6: 1.5rem; /* 24 */
--space-8: 2rem; /* 32 — 카드 패딩, 그룹 간격 */
--space-12: 3rem; /* 48 */
--space-16: 4rem; /* 64 */
--space-24: 6rem; /* 96 */
--space-32: 8rem; /* 128 — 섹션 간격 */
}
```
**같은 값을 즉석에서 만들지 마라.** 스케일에 없는 값이 반복해서 필요해지면 그 자리에서 리터럴을 쓰지 말고 스케일에 추가(예: 40px이면 `--space-10`)하고 `design.md`에 추가한 이유를 적는다. 한 번만 필요한 값이면 가장 가까운 스케일 값을 쓴다.
### 수직 리듬
- 행 높이를 4px 격자로 반올림한다. 16px × 1.5 = 24px처럼 떨어지게 행간을 고르거나, 떨어지지 않으면 `line-height`를 rem 값으로 준다(한글 16px × 1.7 = 27.2px → 28px이면 `1.75rem`).
- 컴포넌트 높이(`--control-h`)·간격·행 높이를 같은 4px 격자에 둔다. 그래야 카드 두 개를 나란히 놓았을 때 첫 줄과 버튼 높이가 맞는다.
- 엄격한 베이스라인 정렬(모든 텍스트가 한 격자선에 앉는 것)은 에디토리얼·잡지형에서만 연다. 그때는 `text-box: trim-both cap alphabetic`([typography.md](typography.md) §12-2, 점진적 향상)으로 글자 위아래 여백을 깎아 광학적으로 맞춘다. 일반 UI에서는 4px 격자로 충분하다.
### 여백이 위계를 만든다
- 관련 있는 것끼리는 **가깝게**, 다른 그룹과는 **확실히 멀게**. 애매한 중간 간격이 가장 나쁘다
- 섹션·그룹·요소 간 간격 차이가 관계를 읽히게 하는지 실제 화면에서 본다. 4배 같은 고정 비율은 시작 가설일 뿐, 콘텐츠와 폭에 따라 조절한다.
- 요소를 정렬할 때 **간격이 아니라 정렬선**을 먼저 맞춰라
### 제목 역할이 실제로 구분되는지 확인한다
히어로 제목을 줄이다가 실측에서 이렇게 됐다.
| | 크기 | |
|---|---|---|
| h1 (히어로) | 41.5px | `--step-4` |
| h2 (섹션) | 33.2px | `--step-3` |
**8px 차이.** 스케일상으로는 한 단계 위지만 화면에서는 같은 크기로 읽힌다.
페이지에서 가장 중요한 문장이 아홉 개의 섹션 제목과 구별되지 않았다.
원인은 과잉 교정이었다. 처음에 `--display`(1440px 에서 112px)를 썼다가
배경 그래픽과 싸워서 줄였는데, 줄이다가 h2 와 붙는 데까지 왔다.
**한 극단에서 도망치다 반대쪽 극단에 도착한 것이다.**
넓은 화면에서 히어로와 섹션 제목이 같은 위계로 읽히지 않는지 비교한다. 2배 안팎은 한 프로젝트의 관찰값일 뿐 기준선이 아니다. 문구 길이, 구도, 여백, 브랜드 타입에 따라 같은 크기 차도 다르게 보인다.
### clamp 값을 역할 관계와 함께 확인한다
여기서 한 번 더 틀렸다. 데스크톱만 보고 고쳤더니 이렇게 됐다.
```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 는 고정이라, 좁아질수록 둘이 만난다.**
데스크톱에서 고친 위계가 모바일에서 그대로 사라진다.
모바일에서 h1과 h2가 거의 같은 크기로 수렴하지 않는지 확인한다. 아래 값은 해당 조합의 예시이며 모든 제목에 적용하는 비율 규칙이 아니다.
```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 배를 고집할 필요는 없다 — 폭이 좁으면 큰 글자가 줄만 늘린다.
최소·중간·최대 폭에서 제목 역할이 구분되는지 확인하고, 필요한 경우 h1과 h2를 함께 유동화하거나 문구·레이아웃을 조정한다.
> 확인법: 한 화면 폭에서만 재지 마라. `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-4); }
/* 제목 다음에 오는 것은 반드시 떨어진다 — 여기 한 곳에서만 정한다 */
h1 + *,
h2 + *,
h3 + *,
.head + * { margin-top: var(--space-6); }
```
세 가지 함정이 있다.
1. **`:where()` 로 감싸면 특이도가 0 이 되어 위의 `p` 규칙에 진다.**
`:where(h2) + :where(.lead)` 는 0-0-0, `p` 는 0-0-1 이다. 아무 효과 없이 조용히 무시된다.
특이도를 갖추고, 캐스케이드에서 `p` **뒤에** 둬라.
2. **제목을 `
` 로 감싼 섹션에서는 인접 형제가 끊긴다.**
그 섹션만 혼자 0px 이 된다. 제목 묶음 클래스도 같은 규칙에 넣어라.
3. **섹션이 각자 `margin-top` 을 또 붙이면 전역이 무력해진다.**
위쪽은 전역이, 아래쪽만 섹션이 정한다 — `margin-block: A B` 가 아니라 `margin-bottom: B`.
### 반복되는 리듬은 값이 아니라 토큰 하나로
같은 리듬을 세 곳에서 쓰면 세 곳이 따로 논다. 실측 사례 — 타임라인에서
정거장 패딩, 마커 원의 `top`(`calc(간격 토큰 + 0.55em)`), 연결선의 `top/bottom` 이
각자 다른 간격 토큰으로 적혀 있었다. 패딩만 한 단계 올리자
**원이 제목보다 24px 위에 떠 버렸다.**
```css
.spine {
--stop-pad: var(--space-12); /* 리듬의 출처는 한 곳 */
}
.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·선)
radius 스타일 휴리스틱이 검사하는 대상이다. **정의하지 않으면 검사할 수 없다.** 값은 프리셋의 radius 범위([presets/README.md](presets/README.md))에서 고른다. 아래는 이름의 형식이고 값은 자리표시다 — 이 자리에 있던 `2px`·`8px` 한 벌이 쇼케이스 여러 종의 토큰에 그대로 들어갔다.
```css
:root {
--radius-control: <프리셋 범위에서>; /* 입력, 버튼, 배지 */
--radius-surface: <역할이 다르면 다른 값>; /* 카드, 패널, 모달 */
--radius-pill: 999px; /* 필, 토글 */
--line-width: 1px;
}
```
성격이 다른 세 벌의 예(컨트롤 / 표면): 각진 인쇄물 `0 / 0`, 중간 `6px / 12px`, 둥근 소비재 `12px / 24px`. 셋 중 하나를 고르라는 뜻이 아니다.
**radius 값은 역할 토큰으로만 쓰고, 역할당 하나다.** 기본 어휘는 위처럼 3종 안팎이다. 입력·카드·필·모달은 서로 다른 반경을 갖는 것이 정상이며, 역할이 늘면 토큰도 늘 수 있다(M3의 shape scale은 7단계다). 문제는 종류 수가 아니라 **역할 없이 즉석에서 만든 값**이다 — 같은 역할의 두 컴포넌트가 다른 radius를 쓰면 우연히 결정된 것으로 보인다. 동심 계산으로 파생한 값(아래)은 세지 않는다.
1종만 쓰는 것도 선택일 수 있다(예: radius 0 전면). 프리셋이 역할별 차등을 요구하면 그쪽을 따른다.
### 동심 radius — 중첩된 둥근 요소의 계산식
**관찰 후보 — 취향이 아니라 기하학이다.** 둥근 요소 안에 다른 둥근 요소를 넣을 때(배지를 담은 카드, 아이콘을 담은 버튼) 바깥 radius를 안쪽 radius와 무관하게 고르면 두 모서리의 곡률이 어긋나 인터페이스가 어색해 보인다. 바깥 radius는 **안쪽 radius + 안쪽 요소까지의 패딩**으로 계산한다.
```
outer-radius = inner-radius + padding
```
예: 안쪽 배지가 `--radius-control`(이를테면 6px)이고 카드 패딩이 `--space-4`(16px)이면 카드는 22px가 되도록 지역 토큰으로 계산한다.
```css
.card { --card-radius: calc(var(--radius-control) + var(--space-4)); border-radius: var(--card-radius); }
```
동심 계산으로 파생한 지역 값은 radius 어휘에 새 종류로 세지 않는다 — 기준 radius에서 계산으로 나온 값이다. 패딩이 24px를 넘으면 이 관계가 시각적으로 느슨해지므로, 그 지점부터는 바깥 radius를 독립적으로 다시 고른다 [SKILL-BETTER-UI].
그림자·깊이 토큰과 radius를 함께 짝짓는 규칙은 [elevation.md](elevation.md) §3(radius·표면과 짝짓기)을 따른다.
---
## 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 을 쓰면 페이지가 불안해 보인다.
**프로젝트 계약 — 새 duration 리터럴을 만들지 않는다.** 외부 레퍼런스나 라이브러리 프리셋이 다른 수치를 제시해도(예: 프레스 피드백 `scale(0.96)`~`scale(0.98)`) 그 값을 위 4단계 duration 토큰 중 가장 가까운 것에 매핑해 쓴다 — 프레스처럼 즉시 반응해야 하는 상태 변화는 `--dur-instant`가 기본 후보다 [SKILL-APPLE-DESIGN][SKILL-BETTER-UI]. 값 자체(0.96 대 0.98)의 차이는 실질적이지 않으므로 번호를 맞추려 하지 마라.
**관찰 후보 — 드로어류에는 별도 이징 곡선을 조건부로 둘 수 있다.** iOS 스타일 드로어가 브리프의 요구일 때만 위 3단계 이징에 추가하지 않고 드로어 컴포넌트 스코프에 로컬로 `--ease-drawer`를 정의해 쓴다. 값과 적용 범위는 `references/motion.md`(§1 이징 각주)와 일관되게 유지한다 [SKILL-EMIL-DESIGN-ENG].
**프로젝트 계약 — `prefers-contrast`에도 반응한다.** WCAG 성공 기준은 아니지만, `prefers-reduced-motion`만 처리하고 대비를 더 원하는 사용자 설정을 무시하면 색 토큰이 그 사용자에게는 그대로 방치된다. `prefers-contrast`의 지원 상태와 판정 위계는 [accessibility.md](accessibility.md) §12가 정본이고, 램프를 실제로 재조정할 때 쓰는 색 쪽 기법(명도 우선 조정·재측정)은 [color.md](color.md) §6을 따른다 [SKILL-APPLE-DESIGN].
---
## 웨이트와 컨트롤 — 자주 빠지는 두 축
타입 스케일·색·간격은 대부분 토큰으로 잡는다. **웨이트와 컨트롤 치수는 거의 안 잡는다.**
그래서 SSOT 가 거기서 먼저 깨진다.
### 웨이트는 역할 토큰 셋에서 시작한다
```css
--weight-body: 400;
--weight-medium: 500;
--weight-strong: 600;
--weight-display: 750; /* 선택. 브리프 근거가 있을 때만 — 300 라이트 디스플레이, 800 블랙 헤드라인 등 */
```
본문·UI는 역할 토큰 3개에서 시작하고, 디스플레이용 `--weight-display` 하나를 브리프 근거가 있으면 더 둔다. 한글은 같은 숫자에서 더 무겁게 보이므로 실제 값은 [typography.md](typography.md) §6-2를 보고 정한다.
컴포넌트마다 `font-weight: 560` 같은 값을 직접 쓰기 시작하면 페이지에 웨이트가 여섯 종이 된다.
실측 사례: designpaca 소개 페이지가 `400 / 500 / 560 / 600 / 620 / 660` 을 동시에 쓰고 있었고,
그중 셋은 토큰에 없는 값이었다. 문제는 값의 개수가 아니라 **역할 없이 즉석에서 만든 값**이다.
가변 폰트는 660 같은 비정수 웨이트도 그린다. 쓸 수 있지만 **토큰으로만** 쓴다. 폴백 폰트에는 그 웨이트가 없어 가장 가까운 정적 웨이트로 떨어지므로, 폰트를 받지 못한 상태에서 위계가 유지되는지 확인한다.
**위계는 크기·굵기·색·여백 중 둘 이상의 차이로 만든다.** 굵기 하나만으로 만든 위계는 약하고, 굵기를 배제할 이유도 없다. 참고 관찰: R1 실측(zed.dev 모바일)은 `font-weight: 400` 이 832회, 나머지 전부 합쳐 12회로 크기와 색이 위계를 거의 다 만들었다 — 한 사이트의 선택이지 규범이 아니다.
### 버튼처럼 생긴 것은 전부 같은 치수를 쓴다
```css
--control-h: 2.75rem; /* 프로젝트의 편안한 기본 높이 예시 */
--control-h-sm: 2.25rem; /* 36px — 헤더 등 조밀한 자리 */
--control-pad-x: var(--space-3);
```
같은 역할의 컴포넌트는 치수 토큰을 공유한다. 다른 밀도나 위험도는 별도 역할 토큰으로 설명한다. 포인터 target의 최소 크기와 44px 계약의 적용 범위는 [accessibility.md](accessibility.md) §8이 정본이다.
실측 사례: 같은 페이지의 두 버튼이 높이 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-4) + var(--space-2) * 2 + var(--control-h-sm));
```
`position: fixed` 헤더는 문서 흐름에서 빠져 있어서, 첫 섹션의 상단 여백과 앵커 목적지
(`scroll-margin-top`)가 그 높이를 알아야 한다. 값을 각자 손으로 맞춰두면
**간격 토큰을 `clamp()` 로 바꾸는 순간 관계가 끊어져 제목이 헤더 뒤로 들어간다.**
값이 아니라 관계를 토큰으로 둬라.
### 큰 간격은 화면에 반응해야 한다
작은 값은 고정값부터, 큰 공간은 `clamp()`부터 검토할 수 있다. 어느 쪽이든 콘텐츠·확대·컨테이너 조건에서 관계가 유지되는지 확인한다.
```css
--space-block: clamp(2.5rem, 1.6rem + 2.6vw, 4rem); /* 블록 사이 */
--space-section: 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 | 측정 |
| INP | field p75 200ms 이하 | 브리프에 명시 | RUM/CrUX. lab에서는 TBT 200ms 이하(모바일 CPU 4배 스로틀)를 proxy로 표기 |
| LCP | field p75 2.5초 이하를 목표로 검토 | 브리프에 명시 | RUM/CrUX 또는 local proxy 표기 |
| CLS | field p75 0.1 이하를 목표로 검토 | 브리프에 명시 | RUM/CrUX 또는 local proxy 표기 |
| LCP 이미지 우선순위 | LCP 이미지는 lazy 금지, `fetchpriority="high"`(CSS 배경이면 preload) | 같음 | 실제 LCP 요소가 무엇인지 확인. 상세는 [images.md](images.md) §3-c |
| 폰트 **패밀리** | 프로젝트별 전송량·언어 범위 예산 | 브리프에 명시 | 실제 요청·전송량 확인 |
| 폰트 전송량(첫 화면) | 100KB(라틴) · 한글 1패밀리 200KB · 2패밀리 350KB | 한글 2패밀리 450KB | 측정 — 한글 웹폰트는 유니코드 범위 슬라이스로 받아도 첫 화면에 흔히 150~300KB가 든다(쇼케이스 19종 실측 144~480KB). 넘기면 웨이트 수·`text=` 서브셋·시스템 서체 폴백으로 줄이고, 못 줄이면 이유를 적는다 |
| 애니메이션 속성 | compositing 친화 속성을 우선 검토 | 브리프에 명시 | 성능·reduced-motion·상태 피드백 확인 |
예산을 올렸다면 **올렸다는 사실과 이유를 명시**해라. 조용히 넘기는 것이 가장 나쁘다.
> **폰트는 파일 개수가 아니라 패밀리 수와 전송량으로 센다.** 한글 웹폰트를 유니코드 범위별로
> 수십 개 파일로 쪼개는 방식은 실제 사용 문자 범위·캐시·전송 waterfall에 따라 유효할 수 있다. 파일 개수 자체로 최적화를 판정하지 않는다.
> 브라우저는 페이지에 실제로 쓰인 글자 범위만 받는다. 파일 개수를 줄이라고 요구하면
> 한글 프로젝트를 단일 대용량 파일이라는 잘못된 방향으로 몬다.
### LCP·CLS 를 어떻게 재나
`preflight.md` 가 이 값을 요구한다. INP는 실제 사용자 입력이 있어야 생기는 field 지표다. 로컬에서는 대표 상호작용을 직접 조작하며 `PerformanceObserver`의 `event` 항목으로 지연을 보거나, lab의 TBT를 proxy로 표기한다. 재는 법이 없으면 "미측정"으로 남고, 미측정은 통과가 아니다.
```html
```
프로덕션 빌드에서 재는 편이 개발 서버보다 배포 조건에 가깝다. 다만 PerformanceObserver·Lighthouse 한 번은 **lab proxy**다. CWV의 적합성 주장은 실제 사용자의 field data에서 모바일·데스크톱별 75th percentile로 판단한다. local 값은 회귀 탐지에 쓰고 field 수치와 혼동하지 않는다 (`evidence-ledger.md`의 `CWV`, `CWV-METHOD`).
LCP와 상호작용 지연(`event`) 엔트리는 2025-12부터 Chrome·Firefox·Safari 모두에서 관측된다 — WebKit에서도 같은 스니펫으로 잰다. `layout-shift`는 Chromium에서만 나오므로(확인 2026-10-06) CLS 수치는 Chromium 값이고, Safari에서는 폰트 교체·이미지 로드 전후 캡처 비교로 대신 본다. 클라이언트 라우팅 SPA에서 이 스니펫은 **첫 문서 로드만** 잰다 — 라우트 전환 뒤의 LCP·CLS는 대표 전환을 직접 조작한 트레이스로 따로 잰다. [WEB-BASELINE]
### 예산을 지키는 기본 수단
- 폰트: 상세는 `typography.md` §3~§4. 요약하면 `font-display` 를 의식적으로 고르고, preload 는 첫 화면에 실제로 쓰는 파일만, 폴백 메트릭을 보정하거나 `optional` 을 쓴다
- 이미지: `srcset`·`sizes`로 DPR 2까지의 후보를 주되 실제 표시 크기의 2배를 넘는 파일은 보내지 않는다. 포맷은 AVIF 시작(폴백 판단은 images §3-c), 첫 화면 밖은 `loading="lazy"`, LCP 이미지는 lazy 금지·`fetchpriority="high"`(상세는 [images.md](images.md) §3-c)
- JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import
- 페이지 이동(다중 페이지 사이트): 같은 출처 링크에 Speculation Rules를 건다. 지원 브라우저(Chromium)는 링크에 포인터가 머물 때 다음 문서를 미리 렌더하고, 나머지는 이 스크립트를 무시한다(Baseline Limited, 해 없는 점진 향상 — 확인 2026-10-06 [WEB-BASELINE]).
```html
```
로그아웃·장바구니 담기처럼 GET으로 상태를 바꾸는 URL은 `not`으로 뺀다. 분석·광고 스크립트가 프리렌더를 조회로 세지 않는지 확인한다. `eager`로 올리지 않는다 — 방문자의 데이터와 메모리를 쓴다. 클라이언트 라우팅 SPA에는 해당 없다.
- **측정하지 않은 최적화는 하지 마라.** 대신 예산을 넘겼는지는 반드시 측정해라
---
## 6. 산출물 형식
3단계를 마치면 아래가 실제 값으로 채워져 있어야 한다. 이것이 4단계의 입력이다.
```css
:root {
/* 타입 — 비율 ____ , 디스플레이는 스케일 밖 별도 정의 여부 ____ */
--step--1 ~ --step-5, --leading-*, --measure
/* 색 — 역할별. 다크 테마도 함께 */
--surface, --surface-raised, --ink, --ink-muted, --line, --accent, --accent-ink
/* 간격 */
--space-1 ~ --space-32 (4px 기반, 번호 = px ÷ 4), 필요 시 --space-block·--space-section
/* 형태 — 역할당 radius 하나(기본 3종 안팎). radius 스타일 휴리스틱이 이걸 검사한다 */
--radius-control, --radius-surface, --radius-pill, --line-width
/* 웨이트 */
--weight-body, --weight-medium, --weight-strong, (선택) --weight-display
/* 모션 — 모션을 쓰면 --stagger·--shift-sm·--shift-lg 도(motion.md §1) */
--dur-*, --ease-*
}
```
컴포넌트 기반·Tailwind 프로젝트에서는 이 블록을 새 파일로 만들지 않는다. 같은 역할을 프로젝트의 테마 자리에 넣는다([component-systems.md](component-systems.md) §3·§7-1).
그리고 한 줄로: **성능 예산 = JS ___KB / INP ___ms / LCP ___초 / 폰트 패밀리 ___개**
**정의하지 않은 토큰은 5단계에서 검사할 수 없다.** 쓰지 않을 항목은 "쓰지 않음"이라고 적어라 — 빈칸과 "없음"은 다르다.
> 근거: 저장소 조사 문서 03-trends-2026, 04-ai-slop-signatures (저장소 전용, 설치본 미포함, 조사일 2026-08-20)