designpaca/packages/skill/references/tokens.md
Yun Chan 88c11682b1 feat(skill): route fixes, delivery report, composition diversity, platform refresh
- route table: extension route for products with an existing visual
  language, feedback row, derived state tokens stay local
- completion: blocking 0 plus reported unverified; hard-gate surface is
  the touched scope; delivery report starts with what and how to open
- design.md created at step 0 with a skeleton, fingerprint line and
  change log; product-surface rubric reading; direction delegation
  states the chosen card and reason
- layout: hero families, nav forms, footer forms; art-direction page
  convergence 6 axes; genre sections; image treatments; placeholder
  conventions; multi-screen order; Tailwind theme mapping
- platform: scroll-driven examples, anchor positioning, canvas OT,
  three/R3F pins, shadcn style prefixes, Safari 26 notes, subgrid
- design-gate: four result states, exit codes, cwd module resolution,
  --url/--out-dir/--strict, oklch contrast, dead selector word match
- Korean font budget split from the Latin baseline
2026-10-06 16:55:38 +09:00

31 KiB
Raw Blame History

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

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

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


이 문서를 읽는 법

상황 읽을 곳
3단계에서 토큰 전체를 정한다 §1~§4 + "웨이트와 컨트롤"(§4 뒤, 번호 없는 절) + §6. §5는 예산을 정할 때
타입 스케일·행간·measure를 정한다(한글 값의 정본은 typography.md §6-5) §1
역할 색 토큰 §2(방법은 color.md)
간격 스케일·수직 리듬·제목 간격 §3
radius·선 §3-b
모션 토큰 §4
웨이트·컨트롤 치수·헤더 높이 "웨이트와 컨트롤"
성능 예산(INP·LCP·CLS·전송량) §5
3단계 산출물 형식(Tailwind·컴포넌트 기반이면 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가 다른 비율이나 별도 크기를 요구할 수 있으므로, 토큰 이름·역할·대표 폭에서의 위계 검증을 함께 기록한다.

실제 값으로 적는다

: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 — 디스플레이 */
}

유동 크기와 구조 전환을 구분한다

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

clamp(최소, 기준+vw, 최대)는 연속적으로 변해야 하는 값에 유용하다. 최소·최대는 대표 콘텐츠로 확인한다. grid 열, 내비, 작업대처럼 구조가 바뀌는 지점에는 미디어쿼리나 container query를 쓰고, 임의 기기 폭이 아니라 실제 실패 지점에서 정한다.

함께 정해야 하는 것

토큰 규칙
--leading-tight 1.11.2부터 검토(라틴), **한글 표제 1.21.35** — 디스플레이/헤드라인
--leading-normal 1.51.6(라틴), **한글 1.7 시작·범위 1.61.8·하한 1.5** — 본문
--measure 한 줄 길이의 시작 범위. 라틴 6075자, **한글 3240자(좁은 컬럼 20자대까지)**. 한글은 em으로 적고 ch로 적지 않는다
자간 em 단위로만. 큰 라틴 디스플레이에서 작은 음수 자간을 후보로 두고 실제 렌더로 확인. 한글은 본문 0~-0.01em, 큰 표제 -0.02em 시작 + 글리프 충돌 검사

한글 값의 정본은 typography.md §6-5다. 위 표는 그 값을 토큰 자리에 옮긴 요약이다. 둘이 다르면 §6-5를 따르고 이 표를 고친다.

행간과 measure 범위는 시작값이다. 한글 서체·문자·폭·실제 문단을 렌더하고 확대 상태까지 확인해 정한다. 폰트 수는 전송량·언어 범위·위계·라이선스를 함께 보고 예산으로 정한다. 두 텍스트 패밀리와 필요 시 모노는 흔한 시작점이지만, 세 번째 패밀리가 무조건 실패라는 뜻은 아니다.

관찰 후보 — 라틴 측정폭 60자 하한은 고정선이 아니다. 사이드바·카드처럼 컬럼 자체가 좁을 때는 4559자 구간도 시작값으로 허용한다. 다른 스킬은 4575자 범위를 제시하지만, 이 스킬의 6075자·한글 3240자 값을 대체하는 것이 아니라 좁은 컬럼에서만 여는 예외다 [SKILL-DESIGN-REVIEW]. 한글 좁은 컬럼의 예외(20자대)도 같은 성격이다.

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


2. 색

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

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

:root {
  --surface:        /* 페이지 바탕 */
  --surface-raised: /* 카드·패널 (바탕과 구분되되 튀지 않게) */
  --ink:            /* 본문 텍스트 */
  --ink-muted:      /* 보조 텍스트 — 대비 4.5:1 유지 */
  --line:           /* 경계선 */
  --accent:         /* 강조 역할이 필요할 때의 기본 토큰 */
  --accent-ink:     /* 강조 위에 올라가는 글자색 */
}

색 체계의 램프 생성·OKLCH 파생·그라디언트 보간·다크모드 재조정·대비 수정 절차는 color.md 가 정본이다. 여기서는 역할 토큰 7개만 정한다. 여기 없는 역할이 필요할 때(상태색을 더 세분하거나 채색 액션을 늘릴 때) 확장하는 방법은 color.md §8을 본다.

규칙

  1. 강조 역할은 한 색에서 시작해 경쟁 여부를 검증한다. 여러 강조 역할이 필요할 수 있으며, 그때는 행동·상태·정보 목적을 역할 토큰으로 구분한다. 수만으로 위계 실패를 판정하지 않는다
  2. 색 맥락 관찰은 visual-design-lineage.md를 참고한다. 이 스킬에서는 브랜드 강도·정보 위계·접근성의 근거로 색을 고르고, 실제 상태·면적·인접색에서 텍스트 대비와 판독성을 별도로 검증한다
  3. 중성색도 색이다. 순수 회색과 색조를 띤 중성색을 모두 후보로 두고, 표면·브랜드·상태색과의 관계를 비교한다
  4. 대비를 측정해라. 본문 4.5:1, 큰 글자 3:1. 눈으로 판단하지 마라

다크 모드

다크가 슬롭인 게 아니라 고르지 않은 다크가 슬롭이다. (antipatterns.md §1 — bun.sh 는 다크인데도 슬롭 항목을 거의 전부 회피한다)

다크를 기본으로 하려면 근거를 한 줄로 대라. 정당화되는 이유의 목록은 presets/dark-instrument.md 에 있다(야간 운영 환경, 밝은 데이터 시각화의 대비, 제품 자체가 어두움). 댈 수 없으면 다크를 기본으로 삼지 않는다. 그렇다고 밝은 오프화이트가 자동으로 답이 되는 것은 아니다 — 배경 명도는 프리셋의 범위(presets/README.md, 중간 명도·유채색 면 포함)에서 브리프 근거로 고른다.

라이트/다크 모두를 지원하기로 한 제품은 역할 토큰을 각각 정의하고 실제 상태를 검증한다. 제품이 한 테마만 제공할 수는 있지만, 사용자 환경·브리프·운영 맥락과 접근성 영향을 명시한다.

지원할 때는 색을 뒤집는 게 아니라 역할별로 다시 정의한다. 순수 검정·흰색과 근접한 색은 제품·디스플레이·주변광에 따라 읽기 경험이 다르므로, 대비·눈부심·브랜드 표면을 실제 기기에서 확인한다.

:root { --surface: <밝은 테마 표면>; --ink: <그 위에서 대비를 잰 본문색>; }
@media (prefers-color-scheme: dark) {
  :root { --surface: <어두운 테마 표면 — 뒤집지 않고 다시 정한 값>; --ink: <다시 잰 본문색>; }
}

값을 자리표시로 둔 것은 의도다. 이 자리에 있던 웜 오프화이트·니어블랙 한 쌍이 쇼케이스 표면색의 대부분이 됐다. 성격이 다른 세 쌍의 예는 color.md §5에 있다.


3. 간격

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

: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 §12-2, 점진적 향상)으로 글자 위아래 여백을 깎아 광학적으로 맞춘다. 일반 UI에서는 4px 격자로 충분하다.

여백이 위계를 만든다

  • 관련 있는 것끼리는 가깝게, 다른 그룹과는 확실히 멀게. 애매한 중간 간격이 가장 나쁘다
  • 섹션·그룹·요소 간 간격 차이가 관계를 읽히게 하는지 실제 화면에서 본다. 4배 같은 고정 비율은 시작 가설일 뿐, 콘텐츠와 폭에 따라 조절한다.
  • 요소를 정렬할 때 간격이 아니라 정렬선을 먼저 맞춰라

제목 역할이 실제로 구분되는지 확인한다

히어로 제목을 줄이다가 실측에서 이렇게 됐다.

크기
h1 (히어로) 41.5px --step-4
h2 (섹션) 33.2px --step-3

8px 차이. 스케일상으로는 한 단계 위지만 화면에서는 같은 크기로 읽힌다. 페이지에서 가장 중요한 문장이 아홉 개의 섹션 제목과 구별되지 않았다.

원인은 과잉 교정이었다. 처음에 --display(1440px 에서 112px)를 썼다가 배경 그래픽과 싸워서 줄였는데, 줄이다가 h2 와 붙는 데까지 왔다. 한 극단에서 도망치다 반대쪽 극단에 도착한 것이다.

넓은 화면에서 히어로와 섹션 제목이 같은 위계로 읽히지 않는지 비교한다. 2배 안팎은 한 프로젝트의 관찰값일 뿐 기준선이 아니다. 문구 길이, 구도, 여백, 브랜드 타입에 따라 같은 크기 차도 다르게 보인다.

clamp 값을 역할 관계와 함께 확인한다

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

.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가 거의 같은 크기로 수렴하지 않는지 확인한다. 아래 값은 해당 조합의 예시이며 모든 제목에 적용하는 비율 규칙이 아니다.

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

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. 제목을 <div class="head"> 로 감싼 섹션에서는 인접 형제가 끊긴다. 그 섹션만 혼자 0px 이 된다. 제목 묶음 클래스도 같은 규칙에 넣어라.
  3. 섹션이 각자 margin-top 을 또 붙이면 전역이 무력해진다. 위쪽은 전역이, 아래쪽만 섹션이 정한다 — margin-block: A B 가 아니라 margin-bottom: B.

반복되는 리듬은 값이 아니라 토큰 하나로

같은 리듬을 세 곳에서 쓰면 세 곳이 따로 논다. 실측 사례 — 타임라인에서 정거장 패딩, 마커 원의 top(calc(간격 토큰 + 0.55em)), 연결선의 top/bottom 이 각자 다른 간격 토큰으로 적혀 있었다. 패딩만 한 단계 올리자 원이 제목보다 24px 위에 떠 버렸다.

.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)에서 고른다. 아래는 이름의 형식이고 값은 자리표시다 — 이 자리에 있던 2px·8px 한 벌이 쇼케이스 여러 종의 토큰에 그대로 들어갔다.

: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가 되도록 지역 토큰으로 계산한다.

.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 §3(radius·표면과 짝짓기)을 따른다.


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 을 쓰면 페이지가 불안해 보인다.

프로젝트 계약 — 새 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 §12가 정본이고, 램프를 실제로 재조정할 때 쓰는 색 쪽 기법(명도 우선 조정·재측정)은 color.md §6을 따른다 [SKILL-APPLE-DESIGN].


웨이트와 컨트롤 — 자주 빠지는 두 축

타입 스케일·색·간격은 대부분 토큰으로 잡는다. 웨이트와 컨트롤 치수는 거의 안 잡는다. 그래서 SSOT 가 거기서 먼저 깨진다.

웨이트는 역할 토큰 셋에서 시작한다

--weight-body:    400;
--weight-medium:  500;
--weight-strong:  600;
--weight-display: 750;   /* 선택. 브리프 근거가 있을 때만 — 300 라이트 디스플레이, 800 블랙 헤드라인 등 */

본문·UI는 역할 토큰 3개에서 시작하고, 디스플레이용 --weight-display 하나를 브리프 근거가 있으면 더 둔다. 한글은 같은 숫자에서 더 무겁게 보이므로 실제 값은 typography.md §6-2를 보고 정한다.

컴포넌트마다 font-weight: 560 같은 값을 직접 쓰기 시작하면 페이지에 웨이트가 여섯 종이 된다. 실측 사례: designpaca 소개 페이지가 400 / 500 / 560 / 600 / 620 / 660 을 동시에 쓰고 있었고, 그중 셋은 토큰에 없는 값이었다. 문제는 값의 개수가 아니라 역할 없이 즉석에서 만든 값이다.

가변 폰트는 660 같은 비정수 웨이트도 그린다. 쓸 수 있지만 토큰으로만 쓴다. 폴백 폰트에는 그 웨이트가 없어 가장 가까운 정적 웨이트로 떨어지므로, 폰트를 받지 못한 상태에서 위계가 유지되는지 확인한다.

위계는 크기·굵기·색·여백 중 둘 이상의 차이로 만든다. 굵기 하나만으로 만든 위계는 약하고, 굵기를 배제할 이유도 없다. 참고 관찰: R1 실측(zed.dev 모바일)은 font-weight: 400 이 832회, 나머지 전부 합쳐 12회로 크기와 색이 위계를 거의 다 만들었다 — 한 사이트의 선택이지 규범이 아니다.

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

--control-h:     2.75rem;   /* 프로젝트의 편안한 기본 높이 예시 */
--control-h-sm:  2.25rem;   /* 36px — 헤더 등 조밀한 자리 */
--control-pad-x: var(--space-3);

같은 역할의 컴포넌트는 치수 토큰을 공유한다. 다른 밀도나 위험도는 별도 역할 토큰으로 설명한다. 포인터 target의 최소 크기와 44px 계약의 적용 범위는 accessibility.md §8이 정본이다. 실측 사례: 같은 페이지의 두 버튼이 높이 36 vs 44, 패딩 16 vs 8, 테두리 1px vs 0, 웨이트 400 vs 500 이었다. 규칙이 없으니 전부 달랐다. 반대로 R1 의 두 버튼은 h36 · pad-x10 · r4 · w400 · 14px 로 픽셀 단위까지 같았다.

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

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

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

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

작은 값은 고정값부터, 큰 공간은 clamp()부터 검토할 수 있다. 어느 쪽이든 콘텐츠·확대·컨테이너 조건에서 관계가 유지되는지 확인한다.

--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 §3-c
폰트 패밀리 프로젝트별 전송량·언어 범위 예산 브리프에 명시 실제 요청·전송량 확인
폰트 전송량(첫 화면) 100KB(라틴) · 한글 1패밀리 200KB · 2패밀리 350KB 한글 2패밀리 450KB 측정 — 한글 웹폰트는 유니코드 범위 슬라이스로 받아도 첫 화면에 흔히 150300KB가 든다(쇼케이스 19종 실측 144480KB). 넘기면 웨이트 수·text= 서브셋·시스템 서체 폴백으로 줄이고, 못 줄이면 이유를 적는다
애니메이션 속성 compositing 친화 속성을 우선 검토 브리프에 명시 성능·reduced-motion·상태 피드백 확인

예산을 올렸다면 올렸다는 사실과 이유를 명시해라. 조용히 넘기는 것이 가장 나쁘다.

폰트는 파일 개수가 아니라 패밀리 수와 전송량으로 센다. 한글 웹폰트를 유니코드 범위별로 수십 개 파일로 쪼개는 방식은 실제 사용 문자 범위·캐시·전송 waterfall에 따라 유효할 수 있다. 파일 개수 자체로 최적화를 판정하지 않는다. 브라우저는 페이지에 실제로 쓰인 글자 범위만 받는다. 파일 개수를 줄이라고 요구하면 한글 프로젝트를 단일 대용량 파일이라는 잘못된 방향으로 몬다.

LCP·CLS 를 어떻게 재나

preflight.md 가 이 값을 요구한다. INP는 실제 사용자 입력이 있어야 생기는 field 지표다. 로컬에서는 대표 상호작용을 직접 조작하며 PerformanceObserver의 event 항목으로 지연을 보거나, lab의 TBT를 proxy로 표기한다. 재는 법이 없으면 "미측정"으로 남고, 미측정은 통과가 아니다.

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

프로덕션 빌드에서 재는 편이 개발 서버보다 배포 조건에 가깝다. 다만 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 §3-c)
  • JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import
  • 페이지 이동(다중 페이지 사이트): 같은 출처 링크에 Speculation Rules를 건다. 지원 브라우저(Chromium)는 링크에 포인터가 머물 때 다음 문서를 미리 렌더하고, 나머지는 이 스크립트를 무시한다(Baseline Limited, 해 없는 점진 향상 — 확인 2026-10-06 [WEB-BASELINE]).
    <script type="speculationrules">
    { "prerender": [{ "where": { "and": [ { "href_matches": "/*" },
        { "not": { "href_matches": "/logout" } } ] }, "eagerness": "moderate" }] }
    </script>
    
    로그아웃·장바구니 담기처럼 GET으로 상태를 바꾸는 URL은 not으로 뺀다. 분석·광고 스크립트가 프리렌더를 조회로 세지 않는지 확인한다. eager로 올리지 않는다 — 방문자의 데이터와 메모리를 쓴다. 클라이언트 라우팅 SPA에는 해당 없다.
  • 측정하지 않은 최적화는 하지 마라. 대신 예산을 넘겼는지는 반드시 측정해라

6. 산출물 형식

3단계를 마치면 아래가 실제 값으로 채워져 있어야 한다. 이것이 4단계의 입력이다.

: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 §3·§7-1).

그리고 한 줄로: 성능 예산 = JS ___KB / INP ___ms / LCP ___초 / 폰트 패밀리 ___개

정의하지 않은 토큰은 5단계에서 검사할 수 없다. 쓰지 않을 항목은 "쓰지 않음"이라고 적어라 — 빈칸과 "없음"은 다르다.

근거: 저장소 조사 문서 03-trends-2026, 04-ai-slop-signatures (저장소 전용, 설치본 미포함, 조사일 2026-08-20)