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

17 KiB

tokens — 디자인 토큰과 성능 예산

3단계에서 읽는다. 코드를 쓰기 전에 숫자를 정한다. 구현하면서 색을 고르면 매번 다른 색이 나온다.

토큰은 취향이 아니라 계약이다. 한번 정하면 이후 모든 값은 여기서 나온다. 하드코딩된 값이 하나라도 있으면 수정 요청 한 번에 무너진다.


1. 타입 스케일

비율을 먼저 고른다

비율 인상 언제
Minor Third 1.200 차분, 조밀 정보 밀도가 높은 페이지, 문서, 대시보드
Major Third 1.250 안정 기본값. 대부분의 마케팅 사이트
Perfect Fourth 1.333 또렷한 위계 랜딩 페이지, 제품 소개
Golden 1.618 극적 포트폴리오, 에디토리얼. 중간 단계가 비어 본문이 외로워진다

한 페이지에 비율은 하나다. 헤드라인만 다른 비율을 쓰고 싶으면 그건 비율이 아니라 디스플레이 사이즈를 따로 정의하는 것이다.

실제 값으로 적는다

: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 로, 미디어쿼리로 하지 마라

--step-4: clamp(2.25rem, 1.5rem + 3.75vw, 3.157rem);

clamp(최소, 기준+vw, 최대). 최소값은 모바일에서 읽히는 크기, 최대값은 데스크톱 기준. 중간이 매끄럽게 이어져 중간 뷰포트에서 깨지지 않는다.

함께 정해야 하는 것

토큰 규칙
--leading-tight 1.1~1.2 — 디스플레이/헤드라인
--leading-normal 1.51.6 — 본문 (라틴) / **1.61.8 (한글)**
--measure 한 줄 길이. 라틴 6075자, **한글 2540자**
자간 큰 글자에만 음수(-0.02em 정도). 한글에는 음수 자간 금지

폰트는 최대 2종(+ 코드용 모노 1). 세 번째 텍스트 폰트를 넣고 싶다면 위계를 웨이트로 못 만들고 있다는 신호다.

어떤 폰트를 어떻게 고르고 싣는지는 typography.md 가 정본이다. 여기서는 스케일 값만 정한다. 로딩 전략, 폴백 메트릭 보정, OpenType 기능, 한글 서브셋, 라이선스 확인이 그 문서에 있다.


2. 색

역할로 정의한다. 팔레트로 정의하지 마라

"파랑 5단계"가 아니라 무엇에 쓰이는 색인지로 정의한다.

: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)은 대비가 너무 세서 눈이 아프고, 순수 흰색 텍스트도 마찬가지다.

:root { --surface: #fbfaf8; --ink: #1a1917; }
@media (prefers-color-scheme: dark) {
  :root { --surface: #14130f; --ink: #e8e4dc; }
}

3. 간격

하나의 리듬에서 파생시킨다

: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 를 기준으로 정한다

여기서 한 번 더 틀렸다. 데스크톱만 보고 고쳤더니 이렇게 됐다.

.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 배 이상으로 잡으면 전 구간이 유지된다.

/* 하한 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 가 아니다.

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, 마커 원의 topcalc(var(--space-4) + 0.55em), 연결선의 top/bottom--space-4 로 각자 적혀 있었다. 패딩만 한 단계 올리자 원이 제목보다 24px 위에 떠 버렸다.

.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 이 검사하는 대상이다. 정의하지 않으면 검사할 수 없다.

:root {
  --radius-sm: 2px;   /* 입력, 배지 */
  --radius-md: 8px;   /* 카드, 패널 */
  --radius-pill: 999px;
  --line-width: 1px;
}

서로 다른 radius 값은 3종 미만으로. 어휘가 많을수록 우연히 결정된 것으로 보인다. 1종만 쓰는 것도 정당한 선택이다 — swiss-minimal·anti-grid 에서는 오히려 그쪽이 맞다.


4. 모션 토큰

: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 가 거기서 먼저 깨진다.

웨이트는 세 개다

--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회였다. 위계는 크기와 색이 만든다.

버튼처럼 생긴 것은 전부 같은 치수를 쓴다

--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픽셀 단위까지 같았다.

떠 있는 헤더는 높이를 토큰으로 내놔야 한다

--header-h: calc(var(--space-3) + var(--space-2) * 2 + var(--control-h-sm));

position: fixed 헤더는 문서 흐름에서 빠져 있어서, 첫 섹션의 상단 여백과 앵커 목적지 (scroll-margin-top)가 그 높이를 알아야 한다. 값을 각자 손으로 맞춰두면 간격 토큰을 clamp() 로 바꾸는 순간 관계가 끊어져 제목이 헤더 뒤로 들어간다. 값이 아니라 관계를 토큰으로 둬라.

큰 간격은 화면에 반응해야 한다

작은 값(4~24px)은 고정, 큰 값만 clamp().

--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 가 이 값을 요구한다. 재는 법이 없으면 "미측정"으로 남고, 미측정은 통과가 아니다.

<!-- 페이지에 임시로 넣고 콘솔을 본다. 측정 후 반드시 제거한다 -->
<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단계의 입력이다.

: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)