agentleFS
equinorequinor/skills/skills/typography-scale/SKILL.md

typography-scale

Skill2 starsChanged 2 months agoMITView on GitHub

Defines, ports and reviews algorithmic typographic scales. USE FOR: building a size ramp from one base and ratio, choosing or indexing line-height curves, adding a density axis, porting the Equinor Design System scale, emitting scale tokens, CSS, or Figma variables and text styles. DO NOT USE FOR: measuring a font or deriving an x-height correction (use typography-x-height-alignment), optical padding and control heights, choosing which typefaces to pair.

5 other projects use a version of this, 1 of them edited.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

6 rules. 0 are common, 2 are distinctive, and 4 are very specific to this project.

  • 0

    Common

    Most of the 5 edited versions we read keep these.

  • 2

    Distinctive

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Keep both. Collapsing to one curve plus a literal ratio at the composition layer looks equivalent and is not — see references/positions.md.

    line 68
  2. Very specific:

    Known trade-off, worth deciding explicitly: if the line-height curve is indexed by *step label* (xs is always n=0), then density shifts the …

    line 103
  3. Very specific:

    When a display or mono face sits beside the text face, equal nominal sizes do not look equal, so the correction has to live *somewhere* — …

    line 119
  4. Very specific:

    Take size-adjust out: Keep both mechanisms and you double-correct.

    line 172
  5. Distinctive:

    Keep the algorithm legible in the CSS rather than only in the generator:

    line 200
  6. Distinctive:

    If you simplify any of them, do it knowing the cost and write down why.

    line 269

“Common” is measured: kept by at least 6 in 10 of the 5 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Typographic scale

A scale is an algorithm, not a table of numbers. Ship the formula, derive the values, and let one constant — the base size — carry density. Every value below is reproducible from two lines of arithmetic; nothing is authored twice.

1. Sizes: one base, one ratio, one snap

size(i) = round(base × r^(i / n), snap)
  • r = 2, n steps per octave. The scale doubles every n steps — that is the whole point of an octave-based ratio: lg → five steps up is exactly 2 × lg, at every density. Exact in the formula; the snap costs 0.5px on a few pairs, enumerated in references/positions.md. Arbitrary ratios (1.25, 1.333) have no such landmark, so the ends drift.
  • i is the step index, offset so that one named step is the base. EDS uses ten steps xs … 6xl with lg at the base, so i = labelIndex − 3.
  • snap is a rounding grid, not a formatting choice — see below.
/* five steps per octave, base at lg, snapped to half a pixel */
--font-size-md: round(calc(var(--_base) * pow(2, -1/5)), 0.03125rem);
--font-size-lg: var(--_base);
--font-size-xl: round(calc(var(--_base) * pow(2,  1/5)), 0.03125rem);

Snap to half a pixel (0.03125rem), not to whole pixels. Whole-pixel rounding is too coarse at the small end, where steps are closest together: it misses the intended ratio by up to 38.9% against half-pixel's 12.1%, and puts a +9.09% step next to a +16.67% one in the range that carries body text. Full figures in references/positions.md. Half-pixel keeps the small end proportional while still landing on values a human can say out loud, and sub-pixel type renders fine — it is sub-pixel layout that causes trouble, which is why line-heights snap differently (section 2). Note that 0.03125rem is half a pixel only while the root is 16px: round() snaps in the unit it is given, so changing the root changes the grid.

State the constants somewhere durable. base, n, the step offset and the snap are the entire scale: with them every token regenerates, without them the tokens have to be reverse-engineered.

2. Line-height: a curve indexed by step, snapped to the grid

Large text needs proportionally less leading than small text, so a single ratio is wrong at one end or the other. Use a cubic across the scale — the multiplier barely moves for the first half and falls away sharply at the top, which is an ease-in on the leading, not an ease-out:

multiplier(n) = max − (n / (N−1))³ × drop
line-height   = round(fontSize × multiplier(n), 4px)

Two curves, because reading and scanning want different things:

CurvemaxdropFor
default1.390.29Read text — prose, meant to be consumed
compressed1.130.13Scanned text — UI labels; a wrapped label must read as one block

Keep both. Collapsing to one curve plus a literal ratio at the composition layer looks equivalent and is not — see references/positions.md.

The 4px grid is the invariant; the ratio is the derived value. Snapping the result means the rendered percentages come out non-monotonic — 152, 133, 143, 150, 130, 133, 131, 129, 113, 108 — which looks like a bug and is not. Each line box lands on the layout grid, which is the property that composes; the ratio is just what that costs at that step. Say so in a comment, because someone will find the table and file an issue.

Name the second curve for what it does: compressed describes leading, while squished is Nathan Curtis's term for a spacing proportion, and reusing it makes two unrelated ideas collide inside one expression.

3. Density is one number

compact 0.875   comfortable 1   relaxed 1.15625

Density sets base. Everything else — sizes, line-heights, and any measure derived from a font size — follows without a second table. Ship it as an axis on the root:

[data-density='compact'] { --_base: 0.875rem; }

The attribute must sit on the element that declares --_base: [data-density] on a wrapper does not reach a --_base declared on :root.

Derive companions from the step rather than tabulating them: an icon gap of round(fontSize × 0.618, 2px) stays in proportion at every density, where a flat 8px only looks right at one.

Known trade-off, worth deciding explicitly: if the line-height curve is indexed by step label (xs is always n=0), then density shifts the size ramp underneath a fixed curve, and the same rendered px size gets different leading depending on which label produced it:

16px:  compact/xl = 20px    comfortable/lg = 24px    relaxed/md = 24px

Indexing by absolute size instead makes leading purely a function of size. Both are defensible. Label-indexing is the incumbent in EDS and was kept deliberately — changing it moves rendered values in a shipped product, which is a redesign, not a fix. Decide, record the choice, and do not rediscover it.

4. Building a scale for a paired family

When a display or mono face sits beside the text face, equal nominal sizes do not look equal, so the correction has to live somewhere — either in the ramp or in the @font-face. Which one depends on the target, and the two options are genuinely different outputs, not two packagings of one. typography-x-height-alignment produces the factor this section consumes.

Ask before emitting anything:

Will this be used in a CSS-only environment, or also in Figma, React Native, or other non-CSS targets?

The answer changes the output, not just the packaging.

CSS only → one scale, corrected in @font-face

Emit a single size ramp and let size-adjust correct the secondary face. It corrects continuously, so it also fixes sizes that are not scale steps.

@font-face {
  font-family: 'Equinor';
  src: url('EquinorVariable-VF.woff2') format('woff2-variations');
  size-adjust: 113.7288%; /* generated from the correction token, not typed */
}

Both families now use --font-size-md and look the same size. Nothing else changes.

Also Figma or React Native → one scale per family

size-adjust is a CSS @font-face descriptor. React Native never parses it and Figma has no equivalent, so both would render the uncorrected face while CSS looked right. Emit two ramps:

step   text (reference)   display (× 1.137288, snapped)   line-height (shared)
md               14px                          16px                       20px
lg               16px                          18px                       24px
xl             18.5px                          21px                       24px
5xl              32px                        36.5px                       36px

Four rules make this work:

  • Correct the font size only. Apply the factor to each step and re-snap to the same grid (round(step × correction, 0.5px)). When the reference carries an opsz axis the factor is per step, not per family — Inter's x-height falls 5.5% from opsz 14 to 32 — so sample it at each step first: typography-x-height-alignment/references/optical-size.md.
  • Share the line-heights. Both families use the reference ramp's line-heights at the same step: aligned faces look the same size there, so they get the same line box and the 4px rhythm holds across families.
  • Take size-adjust out. Keep both mechanisms and you double-correct.
  • Check the correction survives the snap. Below half a snap unit at the smallest step it is erased there: × 1.019345 lands as 0.00% at xs/sm but +3.57% at md. Figures in references/positions.md.

scripts/scale.py --correction 1.137288 --display Equinor emits this branch with one factor (positions §8 for the per-step limit). Two numbers under one step label is the correct outcome: display-md = 16 beside text-md = 14 means the same perceived size, reached from different nominal values. Both designer and developer see 16 for a display step, with no hidden multiplier. Off-scale sizes get no correction — acceptable, they are outside the system.

5. Emit: tokens first, then CSS and Figma

scripts/scale.py derives every number from the constants and emits them, tokens first — the DTCG file is the source, and CSS and Figma are outputs derived from it, never the other way round:

skill=.claude/skills/typography-scale             # where the installed copy lives
python3 $skill/scripts/scale.py --check                        # fixtures still hold
python3 $skill/scripts/scale.py --out tokens                   # one file per density
python3 $skill/scripts/scale.py --format figma --out figma     # variables + text styles

Token shape: references/token-shape.md. Figma — density as modes, styles bound to variables, no-MCP fallback: references/figma.md.

Keep the algorithm legible in the CSS rather than only in the generator:

--font-size-xl: round(calc(var(--_base) * pow(2, 1/5)), 0.03125rem);

A reader can see the scale; changing density changes one variable and the browser recomputes. Platforms that cannot evaluate expressions — Figma, React Native — get baked values from the same source, never hand-transcribed.

round() and pow() are CSS Values 4 math functions. round() is Baseline newly, which is a question about the target, not a verdict — so ask it rather than deciding for the reader:

Does this need to support older browsers, or only evergreen ones?

  • Evergreen only — a centrally managed or auto-updating fleet, or a browserslist that says so. Emit the expressions. This is the whole point: the scale stays legible and density recomputes in the browser.
  • Older browsers in scope — emit baked values everywhere and keep the expression in a comment, so the derivation survives even though the browser never sees it.

Defaulting to baked values "to be safe" is not neutral: it discards the readable artefact for a constraint the project may not have, silently. If no matrix is declared and nobody answers, say which way you went. Verify status at https://api.webstatus.dev/v1/features/round-mod-rem (caniuse has no feature for round()) rather than recalling it — newly since 2024-05-17, checked 2026-09-04; expect widely around late 2026, which retires this question.

6. Verification discipline

A scale is a claim that N numbers all follow from four constants. Test it:

  • Every emitted value equals what the formula gives — checked differently per branch of §5. Expressions shipped: read back computed styles from a real browser, because the thing under test is rounding, where a generator's round() can disagree with CSS round(nearest) on exact half-way values. Baked values shipped: there is no expression for the browser to evaluate, so recompute the literals from the four constants; a readback there only proves the browser can parse a number.
  • The five octave exceptions are a fixture. The pairs that miss a clean 2× are enumerated in references/positions.md §6. A sixth means a constant or a snap moved, and fails the build.
  • Auditing an existing build runs in a fixed order: reproduce the preset fixture, recompute the artefacts from the constants, regenerate and diff byte-for-byte, then read values.
  • Every deviation from a previous build is enumerated. Anything not on the list fails the harness. Silence is never a pass.
  • Extrapolated values are flagged where they live — token, CSS, harness output. A scale extended past its evidence says so at the point of use.
  • No off-scale sizes. A hard-coded 20px is outside the system and gets none of its guarantees: grid, paired line-height, or any step correction.

The EDS preset

Asked for a scale "based on the Equinor Design System"? The constants and the comfortable ramp as a fixture: references/eds-preset.md.

Positions this skill takes

Four choices here cost something, and a system under delivery pressure will be tempted to simplify each: half-pixel snapping, the second line-height curve, deriving weight and tracking per step, and ten close steps rather than six wide ones — this is a scale for an application interface, not a page. Each rests on a measurement, not a preference, and the file also records two limits where the snapped output does not deliver what the formula promises: references/positions.md.

If you simplify any of them, do it knowing the cost and write down why.

Representative requests

Four acceptance criteria — the EDS build, the compressed line-heights, the two-family Figma push, and auditing a committed scale — plus a routing check: references/representative-requests.md.

Related

  • typography-x-height-alignment — run it first when two families are involved: it measures the fonts and produces the correction factor that section 4 consumes.
  • css-authoring — the channel-variable pattern for --_base, and the general rule for verifying browser support. Not yet published in this repository; it is the draft on origin/skill/css-authoring.
  • typography-weight-matching — matching weight and letter-spacing across a pair, indexed against these steps.

Provenance

From the EDS token rework in equinor/ids-meetup-oslo-26 (Equinor-internal), prepared for the Into Design Systems Oslo meetup on 9 September 2026: src/formulas.ts (the algorithms), DECISIONS.md decision 1 (indexing the curve by step label), build/css/typography.css (the emitted scale with formulas intact) and test/deviations.ts (the enumerated-deviation discipline in section 6), all under eds-tokens-reworked/.

Optical padding — cap height, half-leading, and landing a control on the 4px grid while keeping an honest line-height — derives from these values but is a spacing concern, and is out of scope here.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.