{"topic":"typography","format":"markdown","content":"# Typography\n\nGoal: hierarchy you can feel without reading. Work top-down: fix the scale, then\nline-height, then wrapping, then details. If the project already has a type\nscale, patch its weak steps instead of replacing it.\n\n## 1. The scale\n\nA type step is a size **with the line-height it is set on**, plus a weight.\nNever ship a bare `font-size`, and never pick a value between steps: if a step\nis missing, the scale is wrong, not the component.\n\n**Four styles, and only four.** A fifth size is never the answer: pick the\nnearest of these, or change the layout.\n\n| Style | Token | Size | Line-height | Weight | Use |\n|---|---|---|---|---|---|\n| Title | `display` | clamp(30px, 1.6vw + 1rem, 38px) | 1.1 | 500 | Page titles, h1 |\n| Heading | `2xl` | 22px | 28px | 500 (550 in components) | Section headings, h2, big figures |\n| Body | `md` | 15px | 22px | 400; 500 for control labels | Text, controls, menus, fields, subheads |\n| Small | `sm` | 13px | 18px | 400; 500 for badges | Secondary text, captions, field messages, meta |\n\nRunning text of more than a couple of lines takes `--ui-leading-prose` (1.6)\nat Body size. Inline code is mono at `--ui-text-code` (0.875em) of the text\nit sits in, which is the same style in another face, not a new one.\n\nTokens: `--ui-text-{sm,md,2xl,display}`, `--ui-leading-{sm,md,2xl,display}`\n(the old `xs`, `lg` and `xl` steps alias into these),\n`--ui-leading-prose`, `--ui-text-code`, `--ui-weight-{regular,medium,semibold}`,\n`--ui-measure`, `--ui-text-touch`.\n\n- Line-heights are px for the UI steps so rows line up: 13px text in a 20px\n  row (a footer beside 14px text, a field message) takes `--ui-leading-md`,\n  not its own 18px.\n- Controls, pills and badges use `line-height: 1` so padding alone sets the\n  box. That's a rule, not a step.\n- Weight: 400 to read, 500 for labels and controls (12–14px UI text at 400\n  looks thin), 550–600 for titles. Large display type never goes past 600.\n- Mono uses the step it sits in: code blocks at `sm` / `md` leading, inline\n  code at `--ui-text-code` (mono sets wider than the text face, so the same\n  size looks bigger).\n\nKeep each step's size, line height and weight together as one set of\ntokens, so they're never mixed across steps:\n\n```css\n:root {\n  --text-2xl: 22px;\n  --text-2xl-line-height: 28px;\n  --text-2xl-weight: 600;\n}\n```\n\n## 2. Letter-spacing: text untracked, headings a touch tighter\n\n**Text up to 17px is untracked.** `letter-spacing` stays `normal` on body,\nlabels and components. Two reasons, both measured in Chromium and WebKit with\nsystem-ui:\n\n- **Any letter-spacing switches optional ligatures off.** At −0.01em, −0.03em\n  and +0.06em alike, Chromium drops the contextual forms of the system face\n  (the colon in `12:30` drops from figure height to the baseline) and the\n  fi / fl / ffi ligatures of faces that have them. WebKit keeps the system\n  face's contextual forms but drops other faces' ligatures.\n  `font-variant-ligatures` never brings them back; `font-feature-settings`\n  brings back some in one engine and not the other. There's no reliable\n  override, only not tracking.\n- **The system face already tracks itself.** SF Pro switches optical size\n  with font size (`font-optical-sizing: auto`, the default) and tightens as\n  it grows: the same line is about 0.06em per letter tighter at 76px than at\n  11px, with no letter-spacing. Negative tracking on a heading doubles that.\n\n**Headings from 22px up carry a little negative tracking**: `--ui-tracking-heading`\n(−0.01em) at 22px and `--ui-tracking-title` (−0.02em) on display sizes. The\nface's own tightening is tuned for reading, and a heading set at its default\nreads as separate letters instead of one shape. At these sizes the system\nface has no ligatures to lose, and a heading rarely contains a time, so the\ncost from the first point doesn't apply. Set it on the heading's class, never\non the `h1`–`h3` elements globally: a small `h2` used as a 13px label must\nstay untracked.\n\nThe other exception is all-caps: short uppercase labels need air,\n`letter-spacing: var(--ui-tracking-caps)` (0.06em). There are no ligatures\nto lose in a three-word uppercase label.\n\nIf the project ships its own web font that has no optical sizes, a large\ndisplay step can carry a small negative tracking. Check it in Chromium with a\nword that has fi or fl and see what you lose.\n\n## 3. Line-height\n\n| Role | Line-height |\n|---|---|\n| Display (32–44px) | 1.1 |\n| Headings (17–22px) | 24 / 28px |\n| UI text (12–15px) | 16 / 18 / 20 / 22px |\n| Running text, any size | 1.6 |\n| Buttons, pills, badges, labels | 1, so padding alone controls the box |\n\nThe most common single miss: headings inheriting body line-height. A 22px\nheading at 1.5 has a hole in the middle of the layout.\n\n## 4. Wrapping, measure, truncation\n\n- **`text-wrap: pretty` is the default for all body text, not an opt-in.**\n  Set it once on the root (`html { text-wrap: pretty; }`); it inherits into\n  everything, so no component has to remember it, it costs nothing where\n  lines don't wrap and removes orphans everywhere they do. Its absence on\n  any multi-line paragraph is a defect.\n- `text-wrap: balance` globally on every heading, caption and legend\n  (`:where(h1, h2, h3, h4, h5, h6, caption, figcaption, legend) { … }`), and on\n  any other text ≤ 3 lines (card titles, section intros, empty-state\n  messages). Balance for headings and short blocks, pretty for prose. Don't\n  put balance on long paragraphs; browsers ignore it past a few lines anyway.\n- Prose measure: `max-width: var(--ui-measure)` (65ch) on paragraphs and\n  lists. A container can be wider for tables and code; the paragraphs inside\n  it can't. Heading measure is *narrower* than body measure so display lines\n  break 2–3 words in.\n- `hanging-punctuation: first` on prose, so an opening quote hangs outside\n  the text edge (Safari; ignored elsewhere).\n- `white-space: nowrap` on pills, buttons and table headers.\n\n### Wrap, don't cut\n\nText a person needs to read in full (a name, an email, a file name, a page\ntitle, a chip's label) wraps. A long unbroken string breaks inside itself\nonly when there's nowhere else to break, and the box around it grows: a chip\nbecomes a rounded box, a row gets taller. Cutting it with \"…\" or a fade hides\nexactly the part that tells two items apart.\n\n```css\n.fileName { composes: wrap from \"../text/text.module.css\"; }\n```\n\nCut text only where the height is fixed for a reason: rows of a virtualized\ntable, a single-line native input. There, use a fade, never an ellipsis,\nand put the full text in `title` too. That's the one exception.\n\nA read-only value inside a fixed-height control (a copy field, a token, a\nURL) is the other case: one line, the end faded, the full value in `title`.\nWrapping inside a control's height spills out of it.\n\n### When text must be cut: fade, not an ellipsis\n\nFade its last 24px out instead of cutting to \"…\". **Only fade when the text\nis actually cut.** A fade on a label that fits looks like a rendering bug.\n\n```css\n.cellValue { composes: truncate from \"../text/text.module.css\"; }\n.excerpt   { composes: truncateLines from \"../text/text.module.css\"; --truncate-lines: 2; }\n```\n\nIt needs no script. `overflow: hidden` makes the element a scroll\ncontainer, and it only has a scroll range while the content overflows. An\nanimation on `scroll(self inline)` sets the fade width; with no range the\ntimeline is inactive and the fade stays 0. The mask runs `to right`, or\n`to left` inside a `dir=\"rtl\"` ancestor. Write that as\n`:is([dir=\"rtl\"]) .x`, not `:dir(rtl)`: the CSS transpiler rewrites\n`:dir()` into a list of `:lang()` matches, which misses `dir` attributes.\nBrowsers without scroll timelines fall back to\nthe ellipsis.\n\n- The full string stays in the DOM, so screen readers read all of it.\n  Where the cut part carries meaning (an extension, a title), also put it in\n  `title`. That's a convenience for mouse users, not the fix: if the cut text\n  is the only way to tell two rows apart, give it room.\n- Never truncate the only label on a control, an error message, or copy you\n  wrote: shorten or wrap instead.\n- Nothing else on the element may set `animation` or `mask-image`. Put a\n  sweep or shimmer on a span inside; nested masks multiply.\n\n### Tabular figures need a face that has them\n\n`font-variant-numeric: tabular-nums` does nothing in a font without the\n`tnum` feature. Check that the font ships it (measure two digits' widths\nwith and without it) before relying on it to stop numbers jiggling. A\nreadout that must keep its width without them reserves the width of its\nwidest value.\n\n### A badge or pill inside a sentence\n\nShorter than the line (20px in a 24px line), its label on the sentence's\nbaseline, 2px of room at each side. Never as tall as the line: it touches\nthe lines above and below, and punctuation butts against it.\n\n## 5. Numerics & details\n\n- `font-variant-numeric: tabular-nums` on anything numeric that changes or\n  aligns vertically: timers, counters, file sizes, prices in lists, line\n  numbers, stat tiles. A two-second fix that kills row jitter.\n- Curly quotes and real apostrophes (’ “ ”) in interface copy; straight\n  quotes stay in code.\n- Ellipsis character (…) not three periods, in copy like \"Searching…\".\n- All-caps eyebrow labels: `xs`, uppercase, `--ui-tracking-caps`, muted.\n  Don't use `font-variant: small-caps` with fonts that don't have them.\n- Code turns ligatures off (`font-variant-ligatures: none`), so `!=` and\n  `=>` stay two characters. tokens.css does this once for `code, kbd, pre,\n  samp`; put mono text in those elements rather than a styled span. Set mono\n  with `font-family` and the size longhands: a `font:` shorthand resets\n  `font-variant-ligatures` and turns them back on.\n\n## 6. Icons beside text\n\nAn icon next to a label should look like the same weight as the label's\nletters: **icon stroke matches the text's stem**.\n\n- Measured on the system face (stem of `l` at 3× scale): a vertical stem is\n  about **0.085em at 400, 0.104em at 500, 0.123em at 600**. 14px / 500 text\n  has a 1.46px stem; 13px / 400 has 1.10px.\n- A stroke in a 16-unit viewBox renders at `size / 16 × stroke` px, so the\n  stroke number is `stem × 16 / icon size`. A 14px icon needs a slightly\n  higher number than a 16px one for the same weight.\n- Size: 16px icons beside 14–16px text, 14px beside 13px (the scale is\n  under \"Icon sizes\" below). The icon's drawn\n  height (about 9.5 of 16 units) then sits between the x-height and the cap\n  height. Centre it on the line box, not the baseline; with `line-height: 1`\n  and `align-items: center` the system face is already optically centred.\n- **A control beside a label that can wrap** (checkbox, radio, switch) sits\n  on the first line, its centre on the middle of that line's capitals:\n  baseline-align it, then shift by `size / 2 − 0.5cap`. Give the `cap` unit\n  a fallback (`@supports not (height: 1cap)` → `0.35em`, half the system\n  face's cap height): without one, Safari before 17.2 drops the control onto\n  the baseline, 2–6px high. A control with no label is centred in its cell.\n\nTokens (16-unit viewBox numbers, measured against DM Sans at 3x: a 15px\nstem is 1.25px at 400 and 1.48px at 500):\n\n| Token | Value | Text it matches |\n|---|---|---|\n| `--ui-icon-stroke-regular` | 1.25 | 400 |\n| `--ui-icon-stroke-medium` | 1.5 | 500 |\n| `--ui-icon-stroke` | medium | what icons read |\n\n**Two tiers, picked by the weight of the label beside the icon.** Icons\nread `stroke-width: var(--ui-icon-stroke)`. Set it once where the text\nweight is known: buttons, tabs, segments, nav items and chips (500) set\nmedium; field adornments, menu and palette rows, and icons in running text\n(400) set regular. An icon-only button is a button: medium. No blends\nbetween the tiers, no third tier (`--ui-icon-stroke-semibold` is the medium\nvalue, kept for older code), no literal `stroke-width`. A glyph drawn\nsmaller than 16px on its own (in a node or a tile) keeps the line:\n`calc(var(--ui-icon-stroke-medium) * 16 / 14)` at 14px. Don't add weight\nto an icon on a filled control to \"hold up\": match the label.\n\n### Icon sizes\n\nFour sizes, picked by the text the icon sits with, never by the component:\n\n| Token | Size | Where |\n|---|---|---|\n| `--ui-icon-md` | 16px | Beside 14–16px text, and in every control (button, field, select, menu row) |\n| `--ui-icon-sm` | 14px | Beside 13px text: sm controls, meta lines, notes, a 13px disclosure |\n| `--ui-icon-xs` | 12px | Only a tiny mark in a node, a corner badge or a 24px tile |\n| `--ui-icon-lg` | 20px | A glyph alone in a 36–44px tile |\n\n- **Every chevron is `--ui-icon-chevron` (14px)**, whatever the text\n  beside it: accordion, reasoning, tool steps, sources, sidebar rows,\n  select and menu triggers. A chevron is a hint, not a glyph: at the\n  icons' 16px it outweighs the label. The Icon component sizes any\n  `chevron-*` this way, so a chevron needs no size of its own.\n- **No sizes in between** (15, 18, 22px). A glyph that needs to read bigger\n  steps to `lg`; one in a tight node steps to `xs`.\n- Sizes read the tokens, never a literal: `width: var(--ui-icon-sm)`.\n\n## 7. Responsive type\n\nDiscrete 2–3 stop jumps for almost everything:\n\n```\ntext-2xl md:text-3xl              /* card/section headings */\ntext-4xl md:text-5xl lg:text-6xl  /* page headings */\ntext-5xl md:text-7xl lg:text-8xl  /* hero */\n```\n\nReserve `clamp()` for hero display type only. Give it a rem intercept so small\nviewports don't collapse: `font-size: clamp(2rem, 5vw + 0.35rem, 4rem)`. When a\ntitle comes from a CMS or user data, size conditionally on length — long titles\nget one step smaller and a wider container.\n\n**Form inputs: minimum 16px on touch** or iOS Safari zooms the page on focus:\n`@media (pointer: coarse) { .input { font-size: var(--ui-text-touch); } }`.\n\nAnchor targets under a sticky header need `scroll-margin-top` (roughly header\nheight + 16px), or in-page links bury headings under the nav.\n\n## 8. Text color hierarchy\n\nPick ONE system and use it everywhere:\n\n- **Alpha ladder** on the foreground color: 100% headings / 80% body /\n  60–70% secondary / 50% meta / 40% disabled-ish. Six rungs ~10% apart is the\n  finest distinction that stays perceptible.\n- **Token ramp**: gray-900 headings, gray-700 body, gray-600 secondary,\n  gray-400 placeholder — with `light-dark()` or dark-mode overrides. The\n  mid-tone secondary color often works unchanged in both modes.\n\nEither way, secondary text must stay ≥ 4.5:1 contrast for body sizes. The\ntoken ramp here is `--ui-foreground` / `--ui-muted` / `--ui-subtle` at 15 / 46 /\n55% lightness (96 / 72 / 60% dark); all three hold 4.5:1 on canvas and\nsurface in both themes. `--ui-subtle` on a filled control sits just under, so\nuse `--ui-muted` there.\nA refined default: body copy slightly muted, headings at full contrast — the\nhierarchy reads even in a squint test.\n\n## 9. Rendering hygiene (once, globally)\n\n```css\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -webkit-tap-highlight-color: transparent;\n  scrollbar-gutter: stable;      /* kills layout shift when modals lock scroll */\n}\n```\n\nSystem font stack as the default: `system-ui, -apple-system,\nBlinkMacSystemFont, \"Segoe UI Variable Text\", \"Segoe UI\", Roboto,\n\"Helvetica Neue\", Arial, sans-serif` plus the color emoji faces. Nothing to\ndownload, no layout shift, and each platform's best-tuned UI face.\n\nLigatures, contextual forms, kerning and optical sizing are all **on by\ndefault**. Leave them on by not fighting them:\n\n- No `letter-spacing` on text under 22px; headings use the heading tokens (section 2).\n- Never `font-optical-sizing: none`: WebKit then sets every size in the\n  display cut, too tight below 20px.\n- Prefer `font-variant-*` to `font-feature-settings`. Feature settings don't\n  merge: a component that sets `\"tnum\"` that way silently drops every other\n  feature it inherited.\n- A `font:` shorthand resets `font-variant-ligatures`, `font-kerning` and\n  `font-optical-sizing` to their defaults. For text that's harmless (the\n  defaults are on); for code it turns ligatures back on.\n\nOn Apple the system face has no fi / fl ligatures to show; what tracking costs\nit is its contextual forms. On Windows and Android the system faces' own\nligatures are what tracking turns off.\n\nIf the project ships web fonts: `font-display: swap` on every face, `.woff2`\nonly, preload only the one face used above the fold. Never add a new font\ndependency yourself.\n"}