From eb3ca69b5d8cbf48d18e2fae9a3fdcedde574a6a Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Thu, 20 Aug 2026 12:42:59 +0900 Subject: [PATCH] =?UTF-8?q?=ED=83=80=EC=9D=B4=ED=8F=AC=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=BC=20=EC=8A=A4=ED=82=AC=20=EC=8B=A0=EC=84=A4=20+=20?= =?UTF-8?q?=ED=8F=B4=EB=B0=B1=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '폰트 관련된 것도 스킬이 좀 잘 해줘야 하는데 그렇지 못한 것 같다'는 지적. 점검해보니 size-adjust, ascent-override, unicode-range, OpenType 기능, FOUT/FOIT, 폰트 라이선스가 스킬 어디에도 없었다. font-display 와 서브셋은 tokens.md 에 한 줄씩뿐이었다. references/typography.md 신설 - 고르는 절차: 개수를 먼저 정하고, 성격을 브리프에서 끌어내고, x-height·대비·웨이트 범위·숫자 형태를 실제로 재서 검증한다 - 가변 폰트: 축, 표준 속성 우선, 가변 축 애니메이션이 왜 게이트 8 위반인지 - 로딩: font-display 값별 선택 기준, preload 조건, unicode-range - 폴백 메트릭: size-adjust 와 override 3종. 재는 코드 포함 - OpenType: tabular-nums 등. font-variant-* 를 font-feature-settings 보다 먼저 - 한글: 동적 서브셋 원리, 웨이트가 라틴과 다르게 보이는 이유, 스택 순서 - 라이선스: 웹 임베딩 허용 여부가 상업 이용 허용과 다르다는 것 그리고 이 페이지가 그 규칙을 어기고 있었다 - 실측: 맑은 고딕이 Pretendard 보다 13.0% 넓다. 교체 순간 줄바꿈이 다시 잡힌다 - fonts.css 신설. OS 별로 폴백을 따로 정의하고 size-adjust 로 맞췄다 - 실측 차이 13.0% -> 0.0% preflight.md 에 폰트 검사 6개를 추가했다. 지금까지 하나도 없었다. --- apps/site/src/i18n/content.ts | 14 +- apps/site/src/layouts/Base.astro | 1 + apps/site/src/styles/fonts.css | 48 ++++ apps/site/src/styles/tokens.css | 5 +- packages/skill/SKILL.md | 4 +- packages/skill/references/layout.md | 1 + packages/skill/references/preflight.md | 9 + packages/skill/references/tokens.md | 7 +- packages/skill/references/typography.md | 307 ++++++++++++++++++++++++ 9 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 apps/site/src/styles/fonts.css create mode 100644 packages/skill/references/typography.md diff --git a/apps/site/src/i18n/content.ts b/apps/site/src/i18n/content.ts index a42a4a3..a85c3de 100644 --- a/apps/site/src/i18n/content.ts +++ b/apps/site/src/i18n/content.ts @@ -258,6 +258,11 @@ export const ko: Content = { h2: "이 페이지를 만들며 실제로 걸린 것", lead: "남을 검사한 결과가 아니라 자신을 검사한 결과다.", items: [ + { + caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었다", + fixed: + "웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힌다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했다.", + }, { caught: "유리 카드를 29개 뿌렸다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였다", fixed: @@ -297,7 +302,7 @@ export const ko: Content = { fixed: "그리드의 본문 폭을 토큰에 묶었다. 스킬 문서의 예시 코드도 같이 고쳤다.", }, ], - note: "여덟 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 마지막 한 건은 기계가 아니라 사람이 잡았다. 카드가 전형적이라는 말 한 줄이었다.", + note: "아홉 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 둘이다. 카드가 전형적이라는 말과, 폰트를 제대로 다루지 못한다는 말.", }, typography: { eyebrow: "한글 조판", @@ -452,6 +457,11 @@ export const en: Content = { h2: "What this page actually got caught on", lead: "Not the result of auditing someone else. The result of auditing itself.", items: [ + { + caught: "Text width jumped 13% at the moment the font swapped", + fixed: + "Before the web font arrives the system font draws instead, and Malgun Gothic runs 13% wider than Pretendard, so line breaks are recalculated on swap. Matched the fallback with size-adjust. Measured difference is now 0.0%. System fonts differ per OS, so each gets its own fallback.", + }, { caught: "29 glass cards. Only the material had changed. The structure was still a row of cards", fixed: @@ -492,7 +502,7 @@ export const en: Content = { "Bound the grid's main column to the token. The skill's own example code was wrong too, so that got fixed as well.", }, ], - note: "All eight were caught while building this page, and fixed after being caught. The last one came from a person, not a checker. One line: the cards look generic.", + note: "All nine were caught while building this page, and fixed after being caught. Two came from a person rather than a checker: the cards look generic, and the typography is not handled well.", }, typography: { eyebrow: "Your language is probably not Latin", diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro index dcd181d..7a118a2 100644 --- a/apps/site/src/layouts/Base.astro +++ b/apps/site/src/layouts/Base.astro @@ -1,4 +1,5 @@ --- +import "../styles/fonts.css"; import "../styles/tokens.css"; import "../styles/base.css"; diff --git a/apps/site/src/styles/fonts.css b/apps/site/src/styles/fonts.css new file mode 100644 index 0000000..0cd7b6c --- /dev/null +++ b/apps/site/src/styles/fonts.css @@ -0,0 +1,48 @@ +/* ============================================================ + 폰트 폴백 메트릭 보정 + + 웹폰트가 늦게 오면 브라우저는 시스템 폰트로 먼저 그리고 나중에 교체한다. + 두 폰트의 폭과 행 높이가 다르면 그 순간 레이아웃이 튄다. CLS 의 흔한 원인이고 + font-display: swap 을 쓰는 한 반드시 일어난다. + + 그래서 폴백 폰트를 실제 폰트의 치수에 맞춘다. + 아래 숫자는 이 페이지에서 직접 잰 값이다. 추정치가 아니다. + + 한글 Pretendard 1434px vs 맑은 고딕 1620px -> 88.5% + Apple SD Gothic Neo 1510px -> 94.9% + 영문 Switzer 1580px vs Arial 1501px -> 105.3% + + 재는 법은 references/typography.md §4 에 있다. + OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의한다. 하나로 뭉치면 + 한쪽 OS 에서는 보정이 틀린다. + ============================================================ */ + +/* ---- 한글: Windows ---------------------------------------- */ +@font-face { + font-family: "KO Fallback Win"; + src: local("Malgun Gothic"), local("맑은 고딕"); + size-adjust: 88.5%; + ascent-override: 108%; + descent-override: 27%; + line-gap-override: 0%; +} + +/* ---- 한글: macOS ------------------------------------------ */ +@font-face { + font-family: "KO Fallback Mac"; + src: local("Apple SD Gothic Neo"), local("AppleSDGothicNeo-Regular"); + size-adjust: 94.9%; + ascent-override: 100%; + descent-override: 25%; + line-gap-override: 0%; +} + +/* ---- 라틴 -------------------------------------------------- */ +@font-face { + font-family: "EN Fallback"; + src: local("Arial"), local("Helvetica Neue"), local("Helvetica"); + size-adjust: 105.3%; + ascent-override: 110%; + descent-override: 28%; + line-gap-override: 0%; +} diff --git a/apps/site/src/styles/tokens.css b/apps/site/src/styles/tokens.css index 9d971b8..d527a07 100644 --- a/apps/site/src/styles/tokens.css +++ b/apps/site/src/styles/tokens.css @@ -95,7 +95,8 @@ /* ---- 타입: 한국어판 --------------------------------------- */ [lang="ko"] { - --font-sans: "Pretendard Variable", Pretendard, system-ui, sans-serif; + --font-sans: "Pretendard Variable", Pretendard, + "KO Fallback Win", "KO Fallback Mac", system-ui, sans-serif; --step-0: 1.0625rem; --step-1: 1.328rem; @@ -119,7 +120,7 @@ /* ---- 타입: 영어판 ------------------------------------------ */ [lang="en"] { - --font-sans: "Switzer", system-ui, -apple-system, sans-serif; + --font-sans: "Switzer", "EN Fallback", system-ui, -apple-system, sans-serif; --step-0: 1rem; --step-1: 1.25rem; diff --git a/packages/skill/SKILL.md b/packages/skill/SKILL.md index 6b97f3e..0d1a80e 100644 --- a/packages/skill/SKILL.md +++ b/packages/skill/SKILL.md @@ -134,6 +134,7 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된 구현 전에 **숫자를 먼저 정한다.** 코드를 쓰면서 색을 고르면 매번 다른 색이 나온다. - 타입 스케일 · 색 역할 · 간격 리듬 · 모션 문법 → `references/tokens.md` +- **폰트를 고르고 싣는 법** → `references/typography.md`. 폰트는 값이 아니라 결정이다. 로딩·폴백 메트릭·라이선스가 여기 있다 - 한글이 들어가면 → `references/antipatterns.md` 의 한글 조판 섹션을 **반드시** 읽어라. 서구 레퍼런스에는 이 정보가 없다 **성능 예산도 여기서 정한다.** 나중에 정하면 이미 늦는다. 전체 표는 `references/tokens.md` §5 하나뿐이다 — 다른 문서에 예산 표를 만들지 마라. @@ -149,7 +150,7 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된 순서가 있다. **레이아웃 → 재질 → 모션.** 거꾸로 가면 화려한데 읽을 수 없는 페이지가 나온다. -**4-1. 레이아웃과 타이포그래피** → `references/layout.md` +**4-1. 레이아웃과 타이포그래피** → `references/layout.md` (+ 폰트 적용은 `references/typography.md` §5, §6) 그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다. **4-2. 재질(surface)** → `references/svg-filters.md` @@ -245,6 +246,7 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는 | `references/reference-method.md` | 1단계 — 레퍼런스를 뜯어볼 때 | | `references/presets/README.md` | 2단계 — 미학 방향을 고를 때 (고른 프리셋 하나만 추가로 읽는다) | | `references/tokens.md` | 3단계 — 토큰을 정할 때 | +| `references/typography.md` | 3단계 — 폰트를 고를 때 / 4-1 — 적용할 때 | | `references/layout.md` | 4-1 — 그리드와 타이포 | | `references/svg-filters.md` | 4-2 — 재질을 만들 때 | | `references/three.md` | 4-3 — 입체가 필요할 때 | diff --git a/packages/skill/references/layout.md b/packages/skill/references/layout.md index 2150e78..379ff7f 100644 --- a/packages/skill/references/layout.md +++ b/packages/skill/references/layout.md @@ -60,6 +60,7 @@ ## 3. 타이포그래피 토큰은 3단계에서 정했다. 여기서는 **적용**이다. +폰트 자체의 로딩·기능·폴백은 `references/typography.md` 를 보라. ### 위계는 3단계면 충분하다 디스플레이 / 섹션 제목 / 본문. h4, h5, h6 까지 시각적으로 구분하려 들면 위계가 무너진다. 필요하면 **웨이트나 색**으로 구분해라, 새 크기를 만들지 말고. diff --git a/packages/skill/references/preflight.md b/packages/skill/references/preflight.md index 535ebba..77f433f 100644 --- a/packages/skill/references/preflight.md +++ b/packages/skill/references/preflight.md @@ -74,6 +74,15 @@ canvas { display: none !important; } **측정하지 않았으면 통과가 아니다.** 예산을 넘겼다면 둘 중 하나다: 이펙트를 빼거나, 예산을 올린 이유를 명시하거나. +### 폰트 (`typography.md` §8 이 전체 목록) + +- [ ] **폴백 메트릭을 보정했거나 `font-display: optional` 을 골랐다.** 둘 다 안 했으면 폰트가 교체되는 순간 레이아웃이 튄다 +- [ ] preload 한 파일이 첫 화면에 실제로 쓰인다. `crossorigin` 이 있다 +- [ ] 수치를 나열하는 곳에 `tabular-nums` 가 있다 +- [ ] 한글 폰트를 명시했고 서브셋을 쓴다 +- [ ] 웹폰트 임베딩 라이선스를 확인해 `design.md` 에 적었다 +- [ ] **폰트를 못 받은 상태로 열어봤다.** 그 상태로도 읽힌다 + ### 자주 걸리는 것 - 폰트 서브셋 안 함 (한글 전체 폰트는 수 MB) - 첫 화면 밖 이미지에 `loading="lazy"` 누락 diff --git a/packages/skill/references/tokens.md b/packages/skill/references/tokens.md index 1197b92..03a955d 100644 --- a/packages/skill/references/tokens.md +++ b/packages/skill/references/tokens.md @@ -50,7 +50,10 @@ | `--measure` | 한 줄 길이. 라틴 60~75자, **한글 25~40자** | | 자간 | 큰 글자에만 음수(`-0.02em` 정도). **한글에는 음수 자간 금지** | -**폰트는 최대 2종.** 세 번째 폰트를 넣고 싶다면 그건 위계를 웨이트로 못 만들고 있다는 신호다. +**폰트는 최대 2종**(+ 코드용 모노 1). 세 번째 텍스트 폰트를 넣고 싶다면 위계를 웨이트로 못 만들고 있다는 신호다. + +> **어떤 폰트를 어떻게 고르고 싣는지는 `typography.md` 가 정본이다.** 여기서는 스케일 값만 정한다. +> 로딩 전략, 폴백 메트릭 보정, OpenType 기능, 한글 서브셋, 라이선스 확인이 그 문서에 있다. --- @@ -212,7 +215,7 @@ Lighthouse 를 쓸 수 있으면 그쪽이 더 정확하다 — 단 모바일 ### 예산을 지키는 기본 수단 -- 폰트: `font-display: swap`, 서브셋(한글은 필수 — 전체 한글 폰트는 수 MB다), `preload` 는 실제로 첫 화면에 쓰는 것만 +- 폰트: 상세는 `typography.md` §3~§4. 요약하면 `font-display` 를 의식적으로 고르고, preload 는 첫 화면에 실제로 쓰는 파일만, 폴백 메트릭을 보정하거나 `optional` 을 쓴다 - 이미지: 실제 표시 크기의 2배까지만, AVIF/WebP, 첫 화면 밖은 `loading="lazy"` - JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import - **측정하지 않은 최적화는 하지 마라.** 대신 예산을 넘겼는지는 반드시 측정해라 diff --git a/packages/skill/references/typography.md b/packages/skill/references/typography.md new file mode 100644 index 0000000..fe31702 --- /dev/null +++ b/packages/skill/references/typography.md @@ -0,0 +1,307 @@ +# typography — 폰트를 고르고 싣는 법 + +3단계에서 폰트를 정할 때, 4-1에서 적용할 때 읽는다. + +**타입 스케일과 간격은 `tokens.md` 가 다룬다.** 이 문서는 **어떤 글자를 쓰고 그것을 어떻게 화면에 올리는가**만 다룬다. + +## 이 문서를 읽는 법 + +| 상황 | 읽을 곳 | +|---|---| +| 폰트를 아직 안 골랐다 | §1 선택 절차. 대부분 여기서 끝난다 | +| 웨이트를 두 개 이상 쓴다 | §2 가변 폰트 | +| 로딩이 느리거나 글자가 늦게 뜬다 | §3 로딩 | +| 폰트 로드 전후로 레이아웃이 흔들린다 | §4 폴백 메트릭. **가장 자주 빠뜨리는 곳이다** | +| 숫자가 흔들리거나 자간이 이상하다 | §5 OpenType | +| 한글이 들어간다 | §6. 조판 규칙 자체는 `antipatterns.md` §8 | +| 배포 전 | §7 라이선스, §8 체크리스트 | + +--- + +## 1. 고르는 절차 + +### 1-1. 먼저 개수를 정한다 + +**두 벌이면 충분하다.** 디스플레이 하나, 본문 하나. 코드나 수치가 있으면 모노 하나를 더한다. + +세 번째 텍스트 폰트를 넣고 싶다면 **위계를 웨이트로 못 만들고 있다는 신호다.** 가변 폰트 한 벌의 300~700 구간이 서로 다른 폰트 두 벌보다 넓은 표현을 준다. + +### 1-2. 성격을 브리프에서 끌어낸다 + +폰트를 고르기 전에 **한 줄로 답한다**: 이 글자는 무엇처럼 보여야 하는가. + +| 브리프가 요구하는 것 | 글자에서 찾을 성질 | +|---|---| +| 정확함, 계기, 데이터 | 좁은 폭, 낮은 대비, 열린 카운터, 모노와 잘 붙는 것 | +| 편집, 읽는 시간, 권위 | 세리프 또는 고대비 산세, 넉넉한 x-height | +| 도구, 중립, 신뢰 | 그로테스크. 성격은 웨이트와 여백이 만든다 | +| 인상, 기억 | 디스플레이 전용 서체. **본문에는 절대 쓰지 않는다** | + +### 1-3. 후보를 실제 값으로 검증한다 + +이름과 인상으로 고르지 마라. 다음 넷을 본다. + +- **x-height 비율** — 소문자 높이 ÷ 대문자 높이. 0.52 미만이면 작은 크기에서 흐려진다 +- **대비** — 획 굵기 차이. 큰 크기에서만 살아나는 폰트가 있다 +- **웨이트 범위** — 필요한 웨이트가 실제로 있는가. 없는 웨이트를 브라우저가 합성하면 뭉개진다 +- **숫자 형태** — 라이닝인가 올드스타일인가, tabular 가 있는가 + +브라우저에서 재는 법: + +```js +// 실제 렌더된 폰트가 무엇인지부터 확인한다. 폴백으로 떨어졌는데 모르는 경우가 많다 +document.fonts.check('16px "Pretendard Variable"'); +[...document.fonts].map(f => `${f.family} ${f.weight} ${f.status}`); +``` + +### 1-4. 기본값을 피한다 + +`Inter` 는 금지가 아니라 **기본값 금지**다. 다국어 UI나 초고밀도 대시보드처럼 이유가 있으면 쓴다. 이유 없이 쓰면 그건 고른 게 아니다. + +같은 이유로 `Fraunces` 와 `Instrument Serif` 도 기본 선택에서 뺀다. 생성 도구가 가장 자주 뱉는 두 서체다. + +> **못 대면 기본값이다.** "왜 이 폰트인가"에 한 문장으로 답하지 못하면 아직 고른 것이 아니다. + +--- + +## 2. 가변 폰트 + +웨이트를 **두 개 이상** 쓰면 거의 항상 가변이 유리하다. 정적 폰트 두 벌보다 가변 한 벌이 작고, 중간 웨이트를 공짜로 얻는다. + +### 축 + +| 축 | 뜻 | 쓸 곳 | +|---|---|---| +| `wght` | 굵기 | 위계. 가장 많이 쓴다 | +| `opsz` | 광학 크기 | 큰 글자와 작은 글자의 획 대비를 자동 조정 | +| `wdth` | 폭 | 좁은 컬럼, 긴 제목 | +| `slnt` / `ital` | 기울기 | 강조 | + +### 쓰는 법 + +```css +/* 표준 속성을 쓴다. font-variation-settings 는 마지막 수단이다 */ +h1 { font-weight: 640; } /* 100 단위가 아니어도 된다 */ +.lead { font-weight: 450; } + +/* opsz 가 있으면 자동 적용된다. 끄지 마라 */ +h1 { font-optical-sizing: auto; } + +/* 표준 속성으로 못 여는 축만 이걸로 */ +.wide { font-variation-settings: "wdth" 112; } +``` + +**`font-variation-settings` 로 `wght` 를 지정하지 마라.** 상속이 끊기고 `font-weight` 와 충돌한다. + +### 애니메이션 + +가변 축은 애니메이션할 수 있지만 **레이아웃을 다시 계산한다.** 하드 게이트 8이 금지하는 종류다. 글자 굵기를 움직이고 싶으면 `opacity` 로 두 겹을 교차시켜라. + +--- + +## 3. 로딩 + +### 3-1. `font-display` 를 고른다 + +| 값 | 동작 | 언제 | +|---|---|---| +| `swap` | 즉시 폴백, 로드되면 교체 | **본문 기본값.** 글자가 먼저 읽힌다 | +| `optional` | 아주 짧게 기다리고, 늦으면 이번 방문은 폴백으로 끝 | CLS 를 0으로 만들고 싶을 때. 첫 방문에 브랜드 서체가 안 보일 수 있다 | +| `fallback` | 짧은 블록 + 짧은 교체 창 | 절충안 | +| `block` | 최대 3초 블록 | **아이콘 폰트 외에는 쓰지 마라** | + +### 3-2. `preload` 는 조건부다 + +**첫 화면에 실제로 그려지는 폰트 파일만** preload 한다. 그 외에는 다른 자원의 대역을 뺏을 뿐이다. + +```html + + +``` + +모노는 보통 코드 블록에만 쓰이므로 preload 하지 않는다. + +### 3-3. 서브셋 + +라틴은 `unicode-range` 로 쪼갠다. 브라우저가 **실제로 쓰인 범위만** 받는다. + +```css +@font-face { + font-family: "Body"; + src: url("/fonts/body-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+2000-206F, U+2190-21BB; + font-display: swap; +} +``` + +**한글은 §6 을 보라.** 범위가 아니라 글자 단위로 쪼개야 한다. + +### 3-4. 셀프 호스팅 vs CDN + +| | 장점 | 대가 | +|---|---|---| +| 셀프 호스팅 | 요청 도메인 하나, 캐시 통제, 서드파티 장애 무관 | 업데이트를 직접 한다 | +| CDN | 설치가 한 줄 | 도메인이 늘고(각각 DNS+TLS), 그 서비스가 죽으면 글자가 죽는다 | + +CDN 을 쓴다면 `preconnect` 를 반드시 건다. 셋 이상의 폰트 도메인은 그 자체가 결함이다. + +--- + +## 4. 폴백 메트릭 — 가장 자주 빠뜨리는 곳 + +폰트가 늦게 오면 브라우저는 폴백으로 먼저 그리고 나중에 교체한다. 두 폰트의 **행 높이와 글자 폭이 다르면 그 순간 레이아웃이 튄다.** 이것이 CLS 의 흔한 원인이고, `font-display: swap` 을 쓰는 한 반드시 일어난다. + +해결은 **폴백 폰트를 실제 폰트의 치수에 맞추는 것**이다. + +```css +/* 실제 폰트 */ +@font-face { + font-family: "Body"; + src: url("/fonts/body-var.woff2") format("woff2-variations"); + font-weight: 300 800; + font-display: swap; +} + +/* 같은 치수로 보정한 폴백. 이름을 따로 준다 */ +@font-face { + font-family: "Body Fallback"; + src: local("Arial"); + size-adjust: 107%; /* 글자 크기를 맞춘다 */ + ascent-override: 92%; /* 행 위 여백 */ + descent-override: 24%; /* 행 아래 여백 */ + line-gap-override: 0%; +} + +body { + font-family: "Body", "Body Fallback", sans-serif; +} +``` + +**숫자는 폰트마다 다르다.** 재는 방법: + +```js +// 두 폰트로 같은 문자열을 그려 폭을 비교한다. 비율이 size-adjust 값이다 +const measure = (family) => { + const el = document.createElement("span"); + el.style.cssText = `position:absolute;visibility:hidden;font:100px ${family};white-space:nowrap`; + el.textContent = "가나다ABCabc0123"; + document.body.appendChild(el); + const w = el.getBoundingClientRect().width; + el.remove(); + return w; +}; +(measure("Arial") / measure('"Pretendard Variable"') * 100).toFixed(1) + "%"; +``` + +`ascent-override` / `descent-override` 는 실제 폰트의 `hhea` 또는 `OS/2` 값을 `unitsPerEm` 으로 나눈 백분율이다. 도구가 없으면 **행 높이가 눈에 띄게 튀지 않을 때까지 조정**하고 그 값을 `design.md` 에 적어라. + +> `font-display: optional` 을 쓰면 이 문제가 사라진다. 대신 첫 방문자가 브랜드 서체를 못 볼 수 있다. **둘 중 하나는 골라야 한다.** 아무것도 안 하는 것이 최악이다. + +--- + +## 5. OpenType 기능 + +### 숫자 + +```css +/* 표에 들어가는 수치. 자리가 흔들리지 않는다 */ +.nums { font-variant-numeric: tabular-nums; } + +/* 본문 속 숫자. 소문자 높이에 맞는다 */ +.prose { font-variant-numeric: oldstyle-nums; } + +/* 분수, 서수 */ +.frac { font-variant-numeric: diagonal-fractions ordinal; } +``` + +**수치를 나열하는 곳에 `tabular-nums` 가 없으면 숫자가 춤춘다.** 대시보드·가격표·측정값 전부 해당한다. + +### 그 외 + +```css +/* 커닝과 리가처는 기본으로 켜져 있다. 끄지 마라 */ +body { font-kerning: normal; } + +/* 대문자 사이 여백 보정. 전부 대문자인 짧은 라벨에만 */ +.caps { font-variant-caps: all-small-caps; letter-spacing: 0.06em; } + +/* 폰트가 제공하는 대체 글자. 무엇이 있는지 확인하고 쓴다 */ +.alt { font-feature-settings: "ss01" 1; } +``` + +`font-variant-*` 표준 속성이 있으면 그걸 쓴다. `font-feature-settings` 는 표준 속성이 없는 기능에만 쓴다 — 한 번 쓰면 다른 기본 기능이 꺼지기 때문이다. + +--- + +## 6. 한글 + +조판 규칙(줄바꿈·행간·자간·한 줄 길이)은 **`antipatterns.md` §8 이 정본**이다. 여기서는 **폰트 자체**만 다룬다. + +### 6-1. 서브셋은 선택이 아니다 + +한글 완성형은 11,172자다. 전체 폰트는 수 MB 다. 반드시 쪼갠다. + +**쪼개는 단위가 라틴과 다르다.** 라틴은 `unicode-range` 로 블록을 나누면 되지만, 한글은 자주 쓰는 글자가 블록에 흩어져 있다. 그래서 **글자 빈도로 나눈 수십 개 조각**을 만들고 각각에 `unicode-range` 를 건다. 브라우저가 페이지에 실제로 쓰인 조각만 받는다. + +이 방식을 **동적 서브셋**이라 부른다. Pretendard 의 `dynamic-subset` 빌드가 그것이고, 파일이 수십 개로 보이는 이유다. + +> **파일 개수로 폰트 예산을 세지 마라.** 한글 웹폰트가 파일 40개인 것은 정상이고 올바른 최적화다. 세야 할 것은 **패밀리 수와 실제 전송량**이다(`tokens.md` §5). + +### 6-2. 웨이트가 라틴과 다르게 보인다 + +같은 700 이라도 한글은 획이 많아 더 무겁게 보인다. **라틴 기준으로 고른 웨이트를 한글에 그대로 쓰면 본문이 답답해진다.** 한글 본문은 400~500, 제목은 600~700 정도에서 시작해 눈으로 맞춘다. + +한글 폰트에는 x-height 개념이 없다. 대신 **글자틀 대비 속공간**을 본다. 속공간이 좁은 서체는 작은 크기에서 막힌다. + +### 6-3. 라틴과 섞을 때 순서 + +```css +/* 라틴 폰트를 앞에, 한글 폰트를 뒤에 둔다. + 순서를 바꾸면 숫자와 영문까지 한글 폰트로 그려져 조악해진다 */ +font-family: "Switzer", "Pretendard Variable", system-ui, sans-serif; +``` + +두 폰트의 크기감이 다르면 `size-adjust` 를 쓴 별도 `@font-face` 로 맞춘다. §4 와 같은 기법이다. + +### 6-4. 한글 폰트를 지정하지 않는 것 + +그 자체가 완성도 미달 신호다. 지정하지 않으면 맑은 고딕이나 애플 SD 산돌고딕으로 떨어지고, 그 둘은 서로 크게 달라서 **화면마다 다른 디자인이 된다.** + +--- + +## 7. 라이선스 + +**배포 전에 확인한다.** 폰트 라이선스 위반은 조용히 있다가 청구서로 온다. + +| 확인할 것 | 왜 | +|---|---| +| **웹폰트 임베딩이 허용되는가** | 데스크톱 설치는 되는데 웹 임베딩은 금지인 폰트가 많다 | +| 페이지뷰 상한이 있는가 | 상업 폰트에 흔하다 | +| 도메인이 묶여 있는가 | 스테이징·프리뷰 도메인이 빠질 수 있다 | +| 수정·서브셋이 허용되는가 | 서브셋도 파생물이다 | +| 크레딧이 필요한가 | CC-BY 계열 | + +**한국 무료 폰트의 함정**: "상업적 이용 무료"가 곧 "웹폰트 임베딩 무료"는 아니다. 눈누(noonnu.cc)는 허용 범위를 표로 보여주니 **웹폰트 항목을 직접 확인**해라. SIL OFL 은 임베딩·수정·재배포가 모두 허용되지만 **폰트 자체를 판매할 수 없고**, 개명 조항이 있는 경우가 있다. + +확인 결과를 `design.md` 에 한 줄로 남긴다: `본문 Pretendard(SIL OFL, 웹 임베딩 가능) / 디스플레이 ___`. + +--- + +## 8. 배포 전 체크리스트 + +- [ ] 폰트 패밀리 수가 예산 안이다(`tokens.md` §5). 텍스트 두 벌 + 모노 하나가 상한 +- [ ] **"왜 이 폰트인가"에 한 문장으로 답할 수 있다** +- [ ] 웨이트를 두 개 이상 쓰는데 정적 폰트를 여러 벌 받고 있지 않다 +- [ ] 없는 웨이트를 브라우저가 합성하고 있지 않다(`font-synthesis-weight: none` 으로 확인) +- [ ] `font-display` 를 의식적으로 골랐다 +- [ ] preload 한 파일이 첫 화면에 실제로 쓰인다. `crossorigin` 이 있다 +- [ ] **폴백 메트릭을 보정했거나, `optional` 을 골랐다.** 둘 중 하나는 했다 +- [ ] 수치를 나열하는 곳에 `tabular-nums` 가 있다 +- [ ] 한글이 있으면 서브셋을 쓴다. 파일 개수가 아니라 전송량으로 쟀다 +- [ ] 라틴 폰트가 폴백 스택 앞에 있다 +- [ ] 한글 폰트를 명시했다 +- [ ] 웹폰트 임베딩 라이선스를 확인하고 `design.md` 에 적었다 +- [ ] 폰트를 못 받은 상태로 페이지를 열어봤다. 그 상태로도 읽힌다 + +> 근거: research/references/03-trends-2026.md §7, 04-ai-slop-signatures.md §8 (조사일 2026-08-20)