@hegemonart/get-design-done 1.16.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -4
- package/.claude-plugin/plugin.json +22 -4
- package/CHANGELOG.md +111 -0
- package/README.md +27 -2
- package/agents/design-auditor.md +65 -1
- package/agents/design-context-builder.md +6 -1
- package/agents/design-doc-writer.md +21 -0
- package/agents/design-executor.md +22 -4
- package/agents/design-pattern-mapper.md +62 -0
- package/agents/design-phase-researcher.md +1 -1
- package/agents/motion-mapper.md +74 -9
- package/agents/token-mapper.md +8 -0
- package/package.json +16 -2
- package/reference/components/README.md +27 -23
- package/reference/components/alert.md +198 -0
- package/reference/components/badge.md +202 -0
- package/reference/components/breadcrumbs.md +198 -0
- package/reference/components/chip.md +209 -0
- package/reference/components/command-palette.md +228 -0
- package/reference/components/date-picker.md +227 -0
- package/reference/components/file-upload.md +219 -0
- package/reference/components/list.md +217 -0
- package/reference/components/menu.md +212 -0
- package/reference/components/navbar.md +211 -0
- package/reference/components/pagination.md +205 -0
- package/reference/components/progress.md +210 -0
- package/reference/components/rich-text-editor.md +226 -0
- package/reference/components/sidebar.md +211 -0
- package/reference/components/skeleton.md +197 -0
- package/reference/components/slider.md +208 -0
- package/reference/components/stepper.md +220 -0
- package/reference/components/table.md +229 -0
- package/reference/components/toast.md +200 -0
- package/reference/components/tree.md +225 -0
- package/reference/css-grid-layout.md +835 -0
- package/reference/data-visualization.md +333 -0
- package/reference/external/NOTICE.hyperframes +28 -0
- package/reference/form-patterns.md +245 -0
- package/reference/image-optimization.md +582 -0
- package/reference/information-architecture.md +255 -0
- package/reference/motion-advanced.md +754 -0
- package/reference/motion-easings.md +381 -0
- package/reference/motion-interpolate.md +282 -0
- package/reference/motion-spring.md +234 -0
- package/reference/motion-transition-taxonomy.md +155 -0
- package/reference/motion.md +20 -0
- package/reference/onboarding-progressive-disclosure.md +250 -0
- package/reference/output-contracts/motion-map.schema.json +135 -0
- package/reference/platforms.md +346 -0
- package/reference/registry.json +445 -220
- package/reference/registry.schema.json +4 -0
- package/reference/rtl-cjk-cultural.md +353 -0
- package/reference/user-research.md +360 -0
- package/reference/variable-fonts-loading.md +532 -0
- package/scripts/lib/easings.cjs +280 -0
- package/scripts/lib/parse-contract.cjs +220 -0
- package/scripts/lib/spring.cjs +160 -0
- package/scripts/tests/test-motion-provenance.sh +64 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
<!-- Source: Phase 18 — get-design-done -->
|
|
2
|
+
|
|
3
|
+
# Variable Fonts & Font Loading
|
|
4
|
+
|
|
5
|
+
## Variable Font Axes
|
|
6
|
+
|
|
7
|
+
### Registered Axes
|
|
8
|
+
|
|
9
|
+
Registered axes have standardized four-character tags and map to familiar CSS properties:
|
|
10
|
+
|
|
11
|
+
| Axis tag | CSS property mapping | Range (typical) | Description |
|
|
12
|
+
|----------|---------------------|-----------------|-------------|
|
|
13
|
+
| `wght` | `font-weight` | 100–900 | Weight |
|
|
14
|
+
| `ital` | `font-style: italic` | 0–1 | Italic (binary or interpolated) |
|
|
15
|
+
| `opsz` | `font-optical-sizing` / `font-variation-settings` | 8–144 | Optical size |
|
|
16
|
+
| `slnt` | `font-style: oblique Xdeg` | -90–0 | Slant |
|
|
17
|
+
| `GRAD` | `font-variation-settings: 'GRAD'` | -200–150 | Grade (weight without layout shift) |
|
|
18
|
+
|
|
19
|
+
Custom axes use all-uppercase four-character tags (e.g., `XTIL`, `WONK`, `SPAC`). Custom axes have no CSS property shorthand and must use `font-variation-settings`.
|
|
20
|
+
|
|
21
|
+
### @font-face with Variable Font Ranges
|
|
22
|
+
|
|
23
|
+
Declare variable fonts with ranges so the browser knows the full axis span:
|
|
24
|
+
|
|
25
|
+
```css
|
|
26
|
+
/* Single variable font file covering full weight range */
|
|
27
|
+
@font-face {
|
|
28
|
+
font-family: 'Inter';
|
|
29
|
+
src:
|
|
30
|
+
url('/fonts/inter-variable.woff2') format('woff2 supports variations'),
|
|
31
|
+
url('/fonts/inter-variable.woff2') format('woff2');
|
|
32
|
+
font-weight: 100 900; /* wght axis range */
|
|
33
|
+
font-style: normal;
|
|
34
|
+
font-display: swap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Separate italic variable font */
|
|
38
|
+
@font-face {
|
|
39
|
+
font-family: 'Inter';
|
|
40
|
+
src: url('/fonts/inter-variable-italic.woff2') format('woff2 supports variations');
|
|
41
|
+
font-weight: 100 900;
|
|
42
|
+
font-style: italic;
|
|
43
|
+
font-display: swap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Font with opsz axis — declare optical size range */
|
|
47
|
+
@font-face {
|
|
48
|
+
font-family: 'Source Serif';
|
|
49
|
+
src: url('/fonts/source-serif-variable.woff2') format('woff2 supports variations');
|
|
50
|
+
font-weight: 200 900;
|
|
51
|
+
font-style: normal oblique -15deg 0deg; /* slnt axis range */
|
|
52
|
+
font-display: swap;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### font-variation-settings Usage
|
|
57
|
+
|
|
58
|
+
`font-variation-settings` is a low-level override. Use registered-axis CSS properties first; fall back to `font-variation-settings` for custom axes or unsupported registered axes.
|
|
59
|
+
|
|
60
|
+
```css
|
|
61
|
+
/* Prefer high-level properties */
|
|
62
|
+
h1 {
|
|
63
|
+
font-weight: 700; /* maps to wght */
|
|
64
|
+
font-style: oblique 5deg; /* maps to slnt */
|
|
65
|
+
font-optical-sizing: auto; /* maps to opsz */
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Use font-variation-settings for custom axes or combined overrides */
|
|
69
|
+
.headline {
|
|
70
|
+
font-variation-settings:
|
|
71
|
+
'wght' 750,
|
|
72
|
+
'GRAD' 50, /* custom Grade axis — no CSS property */
|
|
73
|
+
'opsz' 36;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Dark mode: GRAD axis adjustment (see section below) */
|
|
77
|
+
@media (prefers-color-scheme: dark) {
|
|
78
|
+
body {
|
|
79
|
+
font-variation-settings: 'GRAD' -50;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Do:** Set `font-variation-settings` on a parent and let children inherit.
|
|
85
|
+
**Don't:** Override `font-variation-settings` on a child while expecting inherited axes to persist — the property does not merge; it replaces entirely.
|
|
86
|
+
|
|
87
|
+
```css
|
|
88
|
+
/* WRONG — child loses 'wght' axis set on parent */
|
|
89
|
+
body { font-variation-settings: 'wght' 400, 'GRAD' 0; }
|
|
90
|
+
.bold { font-variation-settings: 'wght' 700; } /* GRAD silently reset to default */
|
|
91
|
+
|
|
92
|
+
/* CORRECT — repeat all axes on child */
|
|
93
|
+
.bold { font-variation-settings: 'wght' 700, 'GRAD' 0; }
|
|
94
|
+
|
|
95
|
+
/* BETTER — use CSS custom properties to manage axes */
|
|
96
|
+
:root {
|
|
97
|
+
--font-wght: 400;
|
|
98
|
+
--font-grad: 0;
|
|
99
|
+
}
|
|
100
|
+
body {
|
|
101
|
+
font-variation-settings: 'wght' var(--font-wght), 'GRAD' var(--font-grad);
|
|
102
|
+
}
|
|
103
|
+
.bold { --font-wght: 700; }
|
|
104
|
+
@media (prefers-color-scheme: dark) {
|
|
105
|
+
:root { --font-grad: -50; }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### font-optical-sizing
|
|
110
|
+
|
|
111
|
+
`font-optical-sizing: auto` lets the browser use the `opsz` axis automatically based on computed `font-size`. It is enabled by default when the font has an `opsz` axis.
|
|
112
|
+
|
|
113
|
+
```css
|
|
114
|
+
/* auto (default) — browser picks opsz value from font-size */
|
|
115
|
+
body { font-optical-sizing: auto; }
|
|
116
|
+
|
|
117
|
+
/* none — disable automatic optical sizing */
|
|
118
|
+
.logo-lockup { font-optical-sizing: none; }
|
|
119
|
+
|
|
120
|
+
/* Manual override via font-variation-settings */
|
|
121
|
+
.caption {
|
|
122
|
+
font-optical-sizing: none; /* disable auto so manual value wins */
|
|
123
|
+
font-variation-settings: 'opsz' 12;
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## font-display and Loading Behavior
|
|
130
|
+
|
|
131
|
+
### FOIT, FOUT, FAIT Definitions
|
|
132
|
+
|
|
133
|
+
| Term | Full name | Behavior |
|
|
134
|
+
|------|-----------|----------|
|
|
135
|
+
| FOIT | Flash of Invisible Text | Browser hides text until the web font loads |
|
|
136
|
+
| FOUT | Flash of Unstyled Text | Browser shows fallback font, swaps to web font when ready |
|
|
137
|
+
| FAIT | Flash of Actually Invisible Text | Hybrid: short invisible period then fallback |
|
|
138
|
+
|
|
139
|
+
**Which is worse:** FOIT is worse for perceived performance and accessibility. Users see blank content, which degrades readability and Cumulative Layout Shift (CLS) scores when text suddenly appears. FOUT is preferable because content is readable immediately.
|
|
140
|
+
|
|
141
|
+
### font-display Values
|
|
142
|
+
|
|
143
|
+
```css
|
|
144
|
+
@font-face {
|
|
145
|
+
font-family: 'Inter';
|
|
146
|
+
src: url('/fonts/inter-variable.woff2') format('woff2');
|
|
147
|
+
font-weight: 100 900;
|
|
148
|
+
font-display: swap; /* change this per use case */
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
| Value | Block period | Swap period | Best for |
|
|
153
|
+
|-------|-------------|-------------|----------|
|
|
154
|
+
| `auto` | Browser default (usually same as `block`) | Varies | Avoid — unpredictable |
|
|
155
|
+
| `block` | ~3s invisible | Infinite swap | Icon fonts where letters must match |
|
|
156
|
+
| `swap` | ~0ms invisible | Infinite swap | Body copy, headings — text visible immediately |
|
|
157
|
+
| `fallback` | ~100ms invisible | ~3s swap | Performance-sensitive text; graceful if font is slow |
|
|
158
|
+
| `optional` | ~100ms invisible | 0s (no swap) | Decorative fonts, hero text; skip swap if font not cached |
|
|
159
|
+
|
|
160
|
+
**Decision guide:**
|
|
161
|
+
|
|
162
|
+
- Body text, headings, UI labels → `font-display: swap`
|
|
163
|
+
- Performance-critical above-the-fold text → `font-display: fallback` (limits FOUT to 3s)
|
|
164
|
+
- Decorative / non-critical typefaces → `font-display: optional` (no layout shift at all on slow connections)
|
|
165
|
+
- Icon fonts (glyph mapping critical) → `font-display: block` (accept FOIT for correctness)
|
|
166
|
+
|
|
167
|
+
```css
|
|
168
|
+
/* Body copy — always visible */
|
|
169
|
+
@font-face {
|
|
170
|
+
font-family: 'Inter';
|
|
171
|
+
src: url('/fonts/inter-variable.woff2') format('woff2');
|
|
172
|
+
font-weight: 100 900;
|
|
173
|
+
font-display: swap;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Optional decorative typeface */
|
|
177
|
+
@font-face {
|
|
178
|
+
font-family: 'Playfair Display';
|
|
179
|
+
src: url('/fonts/playfair-variable.woff2') format('woff2');
|
|
180
|
+
font-weight: 300 900;
|
|
181
|
+
font-display: optional;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* Icon font — glyphs must match */
|
|
185
|
+
@font-face {
|
|
186
|
+
font-family: 'MyIcons';
|
|
187
|
+
src: url('/fonts/myicons.woff2') format('woff2');
|
|
188
|
+
font-display: block;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Preload Strategies
|
|
195
|
+
|
|
196
|
+
### Preload Syntax
|
|
197
|
+
|
|
198
|
+
```html
|
|
199
|
+
<!-- Preload a WOFF2 variable font — crossorigin is required even same-origin -->
|
|
200
|
+
<link
|
|
201
|
+
rel="preload"
|
|
202
|
+
href="/fonts/inter-variable.woff2"
|
|
203
|
+
as="font"
|
|
204
|
+
type="font/woff2"
|
|
205
|
+
crossorigin
|
|
206
|
+
/>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`crossorigin` is mandatory for all font preloads regardless of origin. Without it the browser fetches the font twice.
|
|
210
|
+
|
|
211
|
+
### Which Subset to Preload
|
|
212
|
+
|
|
213
|
+
Preload only the subset(s) used above the fold. Preloading unused fonts wastes bandwidth and delays critical resources.
|
|
214
|
+
|
|
215
|
+
```html
|
|
216
|
+
<!-- Preload only the Latin subset for an English-language site -->
|
|
217
|
+
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
|
|
218
|
+
|
|
219
|
+
<!-- Do NOT preload every unicode-range subset — browser handles lazy loading of others -->
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### How Many Fonts to Preload
|
|
223
|
+
|
|
224
|
+
- **1–2 font files maximum** as a general rule. More preloads compete with images, scripts, and CSS.
|
|
225
|
+
- Preload the **regular weight** (400) of the primary typeface first.
|
|
226
|
+
- Preload a **bold weight** (700) only if it appears above the fold in a critical heading.
|
|
227
|
+
- Never preload fonts with `font-display: optional` — the browser will skip the swap anyway on slow connections.
|
|
228
|
+
|
|
229
|
+
```html
|
|
230
|
+
<!-- Minimal correct preload for a typical site -->
|
|
231
|
+
<head>
|
|
232
|
+
<!-- 1. Primary body font — preloaded -->
|
|
233
|
+
<link rel="preload" href="/fonts/inter-variable.woff2" as="font" type="font/woff2" crossorigin />
|
|
234
|
+
|
|
235
|
+
<!-- 2. Stylesheet that declares @font-face — load after preload hint -->
|
|
236
|
+
<link rel="stylesheet" href="/css/fonts.css" />
|
|
237
|
+
</head>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## WOFF2 Subsetting
|
|
243
|
+
|
|
244
|
+
### unicode-range Descriptor
|
|
245
|
+
|
|
246
|
+
`unicode-range` tells the browser which characters a font file covers. The browser only downloads the file if the page contains a character in that range.
|
|
247
|
+
|
|
248
|
+
```css
|
|
249
|
+
/* Latin subset */
|
|
250
|
+
@font-face {
|
|
251
|
+
font-family: 'Inter';
|
|
252
|
+
src: url('/fonts/inter-latin.woff2') format('woff2');
|
|
253
|
+
font-weight: 100 900;
|
|
254
|
+
font-display: swap;
|
|
255
|
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
|
|
256
|
+
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122,
|
|
257
|
+
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* Cyrillic subset — only downloaded if Cyrillic characters present */
|
|
261
|
+
@font-face {
|
|
262
|
+
font-family: 'Inter';
|
|
263
|
+
src: url('/fonts/inter-cyrillic.woff2') format('woff2');
|
|
264
|
+
font-weight: 100 900;
|
|
265
|
+
font-display: swap;
|
|
266
|
+
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* Greek subset */
|
|
270
|
+
@font-face {
|
|
271
|
+
font-family: 'Inter';
|
|
272
|
+
src: url('/fonts/inter-greek.woff2') format('woff2');
|
|
273
|
+
font-weight: 100 900;
|
|
274
|
+
font-display: swap;
|
|
275
|
+
unicode-range: U+0370-03FF;
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Subsetting Tools
|
|
280
|
+
|
|
281
|
+
**pyftsubset** (part of fonttools — Python):
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
# Install
|
|
285
|
+
pip install fonttools brotli
|
|
286
|
+
|
|
287
|
+
# Subset to Latin characters and output WOFF2
|
|
288
|
+
pyftsubset inter-variable.ttf \
|
|
289
|
+
--output-file=inter-latin.woff2 \
|
|
290
|
+
--flavor=woff2 \
|
|
291
|
+
--layout-features="*" \
|
|
292
|
+
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
|
|
293
|
+
|
|
294
|
+
# Preserve variable font axes
|
|
295
|
+
pyftsubset inter-variable.ttf \
|
|
296
|
+
--output-file=inter-variable-latin.woff2 \
|
|
297
|
+
--flavor=woff2 \
|
|
298
|
+
--layout-features="*" \
|
|
299
|
+
--unicodes="U+0000-00FF" \
|
|
300
|
+
--retain-gids
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**glyphhanger** (Node.js — analyzes a live URL and generates subset):
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
# Install
|
|
307
|
+
npm install -g glyphhanger
|
|
308
|
+
|
|
309
|
+
# Analyze a URL and output subset unicodes
|
|
310
|
+
glyphhanger https://example.com --subset=inter-variable.ttf --formats=woff2
|
|
311
|
+
|
|
312
|
+
# Subset from a text string
|
|
313
|
+
glyphhanger --whitelist="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 " \
|
|
314
|
+
--subset=inter-variable.ttf \
|
|
315
|
+
--formats=woff2
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Fallback Metric Overrides and CLS Prevention
|
|
321
|
+
|
|
322
|
+
When `font-display: swap` triggers, the fallback font (Arial, Georgia, etc.) has different metrics than the web font. The browser reflows layout, causing Cumulative Layout Shift (CLS).
|
|
323
|
+
|
|
324
|
+
**Fallback metric override descriptors** (in `@font-face` of a _fallback_ font face) adjust the fallback to match the web font's metrics, eliminating the reflow.
|
|
325
|
+
|
|
326
|
+
```css
|
|
327
|
+
/* Step 1: Declare the real web font */
|
|
328
|
+
@font-face {
|
|
329
|
+
font-family: 'Inter';
|
|
330
|
+
src: url('/fonts/inter-variable.woff2') format('woff2');
|
|
331
|
+
font-weight: 100 900;
|
|
332
|
+
font-display: swap;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* Step 2: Declare an adjusted fallback with matching metrics */
|
|
336
|
+
@font-face {
|
|
337
|
+
font-family: 'Inter-fallback';
|
|
338
|
+
src: local('Arial');
|
|
339
|
+
size-adjust: 107%; /* scale fallback to match web font cap height */
|
|
340
|
+
ascent-override: 90%; /* match web font ascender */
|
|
341
|
+
descent-override: 22%; /* match web font descender */
|
|
342
|
+
line-gap-override: 0%; /* match web font line gap */
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* Step 3: Use both in the font stack */
|
|
346
|
+
body {
|
|
347
|
+
font-family: 'Inter', 'Inter-fallback', Arial, sans-serif;
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Descriptor Reference
|
|
352
|
+
|
|
353
|
+
| Descriptor | What it adjusts | Value type |
|
|
354
|
+
|------------|----------------|------------|
|
|
355
|
+
| `size-adjust` | Overall em-square scale | `%` (100% = no change) |
|
|
356
|
+
| `ascent-override` | Ascender height above baseline | `%` of em |
|
|
357
|
+
| `descent-override` | Descender depth below baseline | `%` of em |
|
|
358
|
+
| `line-gap-override` | Extra space between lines built into the font | `%` of em |
|
|
359
|
+
|
|
360
|
+
### Finding Metric Values
|
|
361
|
+
|
|
362
|
+
Use the [Font Style Matcher](https://meowni.ca/font-style-matcher/) or the `fonttools` Python library:
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
from fontTools.ttLib import TTFont
|
|
366
|
+
|
|
367
|
+
font = TTFont('inter-variable.ttf')
|
|
368
|
+
head = font['head']
|
|
369
|
+
hhea = font['hhea']
|
|
370
|
+
os2 = font['OS/2']
|
|
371
|
+
|
|
372
|
+
units_per_em = head.unitsPerEm
|
|
373
|
+
ascent = os2.sTypoAscender / units_per_em * 100
|
|
374
|
+
descent = abs(os2.sTypoDescender) / units_per_em * 100
|
|
375
|
+
line_gap = os2.sTypoLineGap / units_per_em * 100
|
|
376
|
+
|
|
377
|
+
print(f"ascent-override: {ascent:.0f}%")
|
|
378
|
+
print(f"descent-override: {descent:.0f}%")
|
|
379
|
+
print(f"line-gap-override: {line_gap:.0f}%")
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**Do:** Tune `size-adjust` first — it has the largest visual impact. Then fine-tune ascent/descent.
|
|
383
|
+
**Don't:** Use `ascent-override: 100%` blindly — 100% is the fallback default; only override when you have real metric data.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Variable Fonts in Dark Mode
|
|
388
|
+
|
|
389
|
+
### GRAD Axis (Grade)
|
|
390
|
+
|
|
391
|
+
The Grade axis (`GRAD`) adjusts apparent weight without changing the advance widths of any glyphs. This means **no layout reflow** when switching between light and dark modes — unlike changing `font-weight`, which can alter character widths.
|
|
392
|
+
|
|
393
|
+
In dark mode, light text on dark backgrounds appears heavier due to irradiation/halation. Lowering `GRAD` compensates for this optical effect.
|
|
394
|
+
|
|
395
|
+
```css
|
|
396
|
+
:root {
|
|
397
|
+
--font-grad: 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
@media (prefers-color-scheme: dark) {
|
|
401
|
+
:root {
|
|
402
|
+
--font-grad: -50; /* reduce apparent weight in dark mode */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
body {
|
|
407
|
+
font-variation-settings: 'wght' var(--font-wght, 400), 'GRAD' var(--font-grad);
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
```css
|
|
412
|
+
/* Theme toggle via data attribute */
|
|
413
|
+
[data-theme="light"] { --font-grad: 0; }
|
|
414
|
+
[data-theme="dark"] { --font-grad: -50; }
|
|
415
|
+
|
|
416
|
+
body {
|
|
417
|
+
font-variation-settings: 'GRAD' var(--font-grad, 0);
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**GRAD vs wght in dark mode:**
|
|
422
|
+
|
|
423
|
+
| Approach | Layout shift | Glyph width change | Correct method |
|
|
424
|
+
|----------|-------------|-------------------|----------------|
|
|
425
|
+
| Change `font-weight` | Yes (possible) | Yes | No |
|
|
426
|
+
| Change `GRAD` axis | No | No | Yes |
|
|
427
|
+
|
|
428
|
+
### Fonts with a GRAD Axis
|
|
429
|
+
|
|
430
|
+
Notable typefaces: Roboto Flex, Google Fonts variable fonts from 2022+, Amstelvar. Check if a font has `GRAD` using `font-variation-settings: 'GRAD' 0` — if it has no effect the font lacks the axis.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## System Font Stacks
|
|
435
|
+
|
|
436
|
+
### Purpose
|
|
437
|
+
|
|
438
|
+
System fonts load instantly (zero network request) and match the platform's native UI. Use them for UI chrome, admin interfaces, and wherever custom branding is not required.
|
|
439
|
+
|
|
440
|
+
### Per-Platform System Fonts
|
|
441
|
+
|
|
442
|
+
| Platform | Primary UI font | Year introduced |
|
|
443
|
+
|----------|----------------|----------------|
|
|
444
|
+
| macOS 10.11+ | San Francisco (`-apple-system`) | 2015 |
|
|
445
|
+
| iOS 9+ | San Francisco (`-apple-system`) | 2015 |
|
|
446
|
+
| Windows 10+ | Segoe UI | 2006 |
|
|
447
|
+
| Windows 11 | Segoe UI Variable | 2021 |
|
|
448
|
+
| Android 4.0+ | Roboto | 2011 |
|
|
449
|
+
| Linux (GNOME) | Cantarell | — |
|
|
450
|
+
| Linux (KDE) | Noto Sans | — |
|
|
451
|
+
|
|
452
|
+
### Modern System Font Stack
|
|
453
|
+
|
|
454
|
+
```css
|
|
455
|
+
/* Full cross-platform system font stack */
|
|
456
|
+
body {
|
|
457
|
+
font-family:
|
|
458
|
+
-apple-system, /* macOS/iOS Safari — San Francisco */
|
|
459
|
+
BlinkMacSystemFont, /* macOS Chrome — San Francisco */
|
|
460
|
+
'Segoe UI Variable', /* Windows 11 — variable version of Segoe UI */
|
|
461
|
+
'Segoe UI', /* Windows 10 */
|
|
462
|
+
system-ui, /* CSS standard keyword (Chrome/Firefox/Safari) */
|
|
463
|
+
Roboto, /* Android, ChromeOS */
|
|
464
|
+
Oxygen, /* KDE Linux */
|
|
465
|
+
Ubuntu, /* Ubuntu Linux */
|
|
466
|
+
Cantarell, /* GNOME Linux */
|
|
467
|
+
'Helvetica Neue', /* macOS pre-San Francisco */
|
|
468
|
+
Arial, /* universal fallback */
|
|
469
|
+
sans-serif;
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Monospace System Stack
|
|
474
|
+
|
|
475
|
+
```css
|
|
476
|
+
code, pre, kbd, samp {
|
|
477
|
+
font-family:
|
|
478
|
+
ui-monospace, /* CSS standard — maps to SF Mono on Apple */
|
|
479
|
+
'Cascadia Code', /* Windows 11 Terminal default */
|
|
480
|
+
'Cascadia Mono',
|
|
481
|
+
'Segoe UI Mono', /* Windows 10 */
|
|
482
|
+
'Ubuntu Mono', /* Ubuntu Linux */
|
|
483
|
+
'Roboto Mono', /* Android */
|
|
484
|
+
Menlo, /* macOS pre-SF Mono */
|
|
485
|
+
Monaco,
|
|
486
|
+
Consolas, /* Windows */
|
|
487
|
+
'Courier New', /* universal fallback */
|
|
488
|
+
monospace;
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### Serif System Stack
|
|
493
|
+
|
|
494
|
+
```css
|
|
495
|
+
.prose {
|
|
496
|
+
font-family:
|
|
497
|
+
ui-serif, /* CSS standard — not yet widely unique per platform */
|
|
498
|
+
Georgia, /* universal, well-hinted */
|
|
499
|
+
Cambria, /* Windows */
|
|
500
|
+
'Times New Roman',
|
|
501
|
+
Times,
|
|
502
|
+
serif;
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Notes on `system-ui`
|
|
507
|
+
|
|
508
|
+
`system-ui` is a CSS level 4 generic family that maps to the OS UI font. It is well-supported (Chrome 56+, Firefox 92+, Safari 11+). Use it as the primary keyword in minimal stacks when you want the standard OS font without specifying exact names:
|
|
509
|
+
|
|
510
|
+
```css
|
|
511
|
+
/* Minimal modern stack */
|
|
512
|
+
body {
|
|
513
|
+
font-family: system-ui, sans-serif;
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
For maximum compatibility with older browsers and to ensure San Francisco on macOS/iOS Safari (which does not respond to `system-ui` in all versions), keep `-apple-system` and `BlinkMacSystemFont` before `system-ui`.
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Quick Reference: Common Mistakes
|
|
522
|
+
|
|
523
|
+
| Mistake | Fix |
|
|
524
|
+
|---------|-----|
|
|
525
|
+
| `font-variation-settings` on child loses parent axes | Use CSS custom properties to compose all axes in one declaration |
|
|
526
|
+
| Preloading without `crossorigin` attribute | Always add `crossorigin` — font fetches are CORS requests |
|
|
527
|
+
| Using `font-display: block` on body text | Use `swap` — block causes FOIT, text invisible for up to 3s |
|
|
528
|
+
| Changing `font-weight` between light/dark modes | Use `GRAD` axis — changes weight appearance without layout shift |
|
|
529
|
+
| Missing `font-weight` range in `@font-face` for variable font | Declare `font-weight: 100 900` so browser knows full range |
|
|
530
|
+
| Subsetting variable font without `--retain-gids` | Add `--retain-gids` to pyftsubset to preserve axis interpolation |
|
|
531
|
+
| Setting `font-optical-sizing: auto` alongside manual `opsz` in `font-variation-settings` | Set `font-optical-sizing: none` first, then set `'opsz'` manually |
|
|
532
|
+
| Preloading every unicode-range split | Preload only the primary Latin subset; browser lazy-loads others |
|