typography-x-height-alignment
Skill3 starsChanged 2 months agoMITView on GitHub
Aligns paired font families by measured x-height so they look the same size at the same step. USE FOR: two fonts that look mismatched at one nominal size, extracting metrics from font files, deriving the correction factor, choosing between size-adjust and baked font-size tokens, emitting the correction as a design token. DO NOT USE FOR: building the size ramp itself (use typography-scale), choosing which typefaces to pair, font licensing or hosting.
- Installs packages
1 other project uses a version of this.
Adopted by
Most recently taken up by
- equinor/skillsword for word, first seen Sep 8, 2026
Other versions1 project uses a version of this.
Word for word · 1
- equinor/skills★ 2100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository7 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- colour-fill-tiersSkill
- css-authoringSkill
- design-system-adrSkill
- spacing-ladderSkill
- typography-scaleSkill
- typography-weight-matchingSkill
The file
Files
x-height alignment
Two faces set at the same nominal size rarely look the same size. Apparent size is x-height, not em: the em square is an arbitrary container, and where a designer put the lowercase letters inside it varies by hundreds of units between families. Set Inter and Equinor both at 16px and Equinor reads smaller — not because it is smaller, but because 48% of its em is lowercase against Inter's 55%.
This skill turns that into one measured number, recorded where a build can check it.
What you need before starting
- The font files themselves —
.ttf,.otf,.woff2, or a URL to fetch. Metrics come out of the binary; nothing else is trustworthy. - Which family is the reference (the "master"). Normally the text face, because body copy is what everything else is judged against. Everything else is corrected towards it. "Align X with Y" means Y is the reference — if the request is ambiguous, say which way you read it rather than guessing silently.
- Which instance, if the fonts are variable — normally the weight the pairing is actually set at. See below; this changes the answer.
Never supply metrics from memory or from a specification page. Font vendors revise metrics between releases — the exact failure documented in section 5. If a font is named but you cannot obtain the file, stop and ask for it; do not demonstrate on the bundled pair and present the result as the named fonts.
The bundled demo pair
assets/fonts/ ships Inter and EB Garamond, both SIL OFL, so the procedure runs
with nothing supplied — the fastest check that your environment works:
Inter xRatio 0.545898 @ wght 400 (reference)
EB Garamond xRatio 0.400000 @ wght 400 correction 1.364746The pair is chosen to teach: a 36% correction cannot be mistaken for rounding,
the two unitsPerEm differ so raw x-heights mislead by a factor of two, and
EB Garamond's x-height moves along the weight axis while Inter's does not — so
1.364746 is the wght 400 answer, not the answer.
What it demonstrates, and why bundling OFL fonts does not contradict the
no-vendoring rule below: references/demo-pair.md.
1. Extract the metrics
fontTools reads every common format; brotli is what lets it open .woff2.
skill=.claude/skills/typography-x-height-alignment # where the installed copy lives
python3 -m venv .venv && .venv/bin/pip install fonttools brotli # project root, not $skill
.venv/bin/python $skill/scripts/xheight.py REFERENCE.otf SECONDARY.woff2Paths here are from the project root, and $skill is the installed copy.
Skip the venv line if the project already has a Python with fontTools and
brotli. Never create anything inside $skill: npx skills update replaces
that directory wholesale, and a venv built there is the classic casualty.
scripts/xheight.py reads OS/2 and head from each file and prints the
metrics, ratios and derived correction as JSON, with warnings on stderr. Run it
with no arguments to measure the bundled demo pair — the fastest check that
your environment works.
It is a file rather than a snippet on purpose: its numbers are load-bearing, and a script retyped from a code block drifts from the one that was verified.
Variable fonts: pin the instance
Most fonts shipped today are variable, and a variable file is measured at one point on its axes. Two traps follow:
- The default instance is often not Regular. Montserrat's variable file
defaults to
wght 100, andnameID1gives it away —"Montserrat Thin". Measured straight, it yields a correction that is plausible, in range, and wrong for any normal pairing. - x-height moves along the axis whenever the font has an
MVARtable, so a single scalar per family only holds at one location. For Montserrat against Open Sans the correction crosses 1.0 nearwght 600— pair at Bold and the "smaller" face needs setting smaller still, inverting the correction. (Measured 2026-08-30 againstgoogle/fonts; re-run it, releases move these.)
.venv/bin/python $skill/scripts/xheight.py --location wght=400 REF.ttf SECONDARY.ttfThe script warns on both traps, names every axis left at its default, and
reports the instance it measured. Pin the weight the pairing is set at; an
out-of-range value is clamped with a warning. An opsz axis cannot be pinned
this way — engines set it per element from the rendered size, so a flat
correction drifts across the ramp. Measure the drift and publish the bound:
references/optical-size.md.
Four things to check in the output before going further:
unitsPerEmdiffers between families — 1000 and 2048 are both common. That is why everything is normalised to a ratio before comparing. Never compare rawsxHeightvalues.xRatiois plausible. Text faces mostly land around 0.45–0.55, but the range is wider: EB Garamond, the bundled demo font, measures 0.400000 and is an ordinary text face. Treat anything below ~0.35 or above ~0.60 as a prompt to check for an icon/display font or a badOS/2table, not as a rejection.- Which
methodfired.OS/2.sxHeightandmeasured:x-glyph-boundsare not the same quality of evidence: measured bounds include overshoot on rounded letters, so a measuredxruns a few units above the true x-height (Amatic SC:OS/2659, glyph 662). Carry the method into the token. - The
instance, and any warnings. A correction with no instance recorded is only meaningful for a static font.
Do not vendor the font binaries to get reproducibility. Many licences forbid
redistribution. Commit the extracted metrics, the source and the extraction date
— measurements are not the font, and they are what the build needs. For a local
file with no URL, record the path and the sha256 the script emits: a checksum
pins the exact bytes measured, which is what the URL stood in for.
2. Derive the correction
correction = referenceXRatio / selfXRatioInter (ref) upm 2048 xHeight 1118 → 0.545898
Equinor upm 1000 xHeight 480 → 0.480000 → 1.137288 (113.73%)
CommitMono upm 1000 xHeight 540 → 0.540000 → 1.010923 (101.09%)The secondary face is then set at nominalSize × correction. At a 14px step
Equinor is set at 15.92px, and the two read as the same size.
The reference family's own correction is 1.0 by definition. Emit it anyway —
a consumer that special-cases "the one without a factor" will break the first
time the reference changes.
3. Emit it as a token, with the derivation attached
The correction is the output of this skill. Emit DTCG, and put the rule and
its inputs in $extensions so a build can recompute and assert rather than
trust a committed number:
"x-height-correction": {
"display": {
"$type": "number",
"$value": 1.137288,
"$extensions": {
"com.equinor.typography": {
"derived": {
"expression": "referenceXRatio / selfXRatio",
"inputs": { "referenceXRatio": 0.545898, "selfXRatio": 0.48 }
},
"metrics": { "family": "Equinor", "unitsPerEm": 1000, "xHeight": 480,
"source": "…", "extractedAt": "2026-08-29",
"method": "OS/2.sxHeight" }
}
}
}
}The committed $value is then checkable: a build reads derived.expression
and its inputs, recomputes, and fails if they disagree.
Full example, the reverse-DNS namespace rule, and why method is recorded:
references/token-shape.md.
4. Delivering the correction
Emitting the factor is not the same as applying it, and the right mechanism depends on where the type will render. Ask before emitting anything beyond the token file:
Will this be used in a CSS-only environment, or also in Figma / React Native / other non-CSS targets? And which weight is the pairing set at?
CSS only → one size ramp for both families, with size-adjust in the
@font-face doing the correction. It corrects continuously, so it fixes the
face at any size, including off-scale ones. This is the simpler output: no
second scale, no per-family font-size tokens.
The percentage must be written by the generator, not by a person — a worked
emitter and the resulting stylesheet are in
references/emit-font-faces.md:
/* Generated — do not edit. Source: tokens/typography.tokens.json */
@font-face {
font-family: 'Equinor';
src: url('https://cdn.example.com/font/EquinorVariable-VF.woff2') format('woff2');
/* generated from OS/2.sxHeight, extracted 2026-08-29 */
size-adjust: 113.7288%;
}The distinction is not cosmetic. A hand-typed 113.7288% is correct the day it
is typed and silently wrong after the next font release; a generated one is
re-derived from the metrics on every build. Two things make that real:
- The stylesheet is a build artefact — generated header, not hand-edited, and ideally not committed.
- A test asserts the emitted percentage still equals
round(refXRatio / selfXRatio × 100, 4)recomputed from the font files, so a font update either moves the value or fails the build. Without that test the generator is just a slower way to produce the same stale literal.
Also Figma, React Native, or anything else → do not use size-adjust.
It is a CSS @font-face descriptor: React Native loads fonts natively and never
parses it, and Figma has no equivalent, so both silently render the uncorrected
face while CSS looks right. Bake the corrected sizes into per-family font-size
tokens instead — that is the typography-scale skill's two-scale output, and
this token file is its input.
Never both. size-adjust and baked sizes double-corrects.
This is not a rule that the least capable platform decides everything. Bake
the correction because it is a value the platforms would otherwise disagree
about — not because Figma and React Native set the ceiling. Capabilities one
platform has and another lacks, such as text-box trimming, are a different case
and should not be levelled down:
references/positions.md.
5. Why this must be derived, not transcribed
A correction is a quotient of two measurements. Written into a stylesheet as a literal it stops being that, and becomes a number that happens to be right today.
The bundled pair shows how fast that expires: EB Garamond against Inter needs
1.364746 at wght 400 and 1.302860 at wght 700, because EB Garamond's
x-height moves along the weight axis and Inter's does not. Nothing about the
literal says which weight it belongs to, so a stylesheet carrying it is silently
wrong for every heading not set at Regular.
Font revisions do the same thing more slowly. A foundry adjusts an x-height
between releases; the transcribed percentage does not move, because nothing
relates it to the metrics any more. The typical symptom is a size-adjust
value with a comment asserting the faces match, in a stylesheet where they
visibly do not — the comment is the last trace of a derivation nobody can rerun.
There is no arithmetic mistake in any of this. The defect is structural: a derived value stored far from its inputs, with no test relating the two, so an upstream change invalidates it silently. No build step can fail.
So:
- Extract in the build, from the fonts actually being served.
- Derive the factor; never write the percentage by hand.
- Record the inputs alongside it — both metrics, the instance, the method — so the claim is checkable rather than merely stated.
- Assert the relationship in a test, so a font update either moves the value or breaks the build.
- Record a discrepancy you cannot resolve — hypothesis, impact, status — rather than quietly correcting it. A wrong number whose reasoning is written down can be diagnosed later; one silently fixed teaches nobody why.
6. Check the result
- Render both at the same corrected step and compare lowercase. The arithmetic can be right and the pairing still wrong — correction aligns x-heights, not stroke weight, width or colour.
- Do not correct the line-height. Both faces look the same size at the same
step, so they share the line box. See
typography-scale. - Check vertical extent before believing an overflow report.
(typoAscender − typoDescender) / unitsPerEm— a face can carry a larger nominal size and still occupy less vertical space, so the corrected face is usually not the one overflowing.
Representative requests
Four acceptance criteria — the no-fonts demo, files given, a font named but
unavailable, and auditing a correction already committed — plus a routing
check, each with the mistake it prevents:
references/representative-requests.md.
Related
typography-scale— applies the correction across a size ramp, and emits the two-scale output for non-CSS targets.typography-weight-matching— once sizes match, the faces may still differ in weight and spacing; its correction takes this one's factor as input.
Provenance
The metric conventions and the $extensions shape come from the EDS token
rework in equinor/ids-meetup-oslo-26
(Equinor-internal), prepared for the Into Design Systems Oslo meetup,
9 September 2026:
| Path | What it establishes |
eds-tokens-reworked/src/font-metrics.json | Committed metrics with their sources, and the extraction discipline |
eds-tokens-reworked/src/formulas.ts | xHeightCorrection() |
eds-tokens-reworked/src/build/tokens.ts | The derived: { expression, inputs } extension shape |
eds-tokens-reworked/DECISIONS.md | Decision 5 — bake the correction; font size only |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

