Install
$ agentstack add skill-monikazapisek-design-engineering-playbook-text-typesetting ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Text Typesetting
Purpose
Compute a coherent line-height and letter-spacing for a given font-size and text role, and correctly account for vertical-trim (CSS leading-trim / Figma textLeadingTrim) when it changes what "line-height" visually means.
When To Use
- Setting up a new text style/token (body, heading, label, caption).
- Reviewing an existing style where line-height or tracking look off.
- Inspecting a selected Figma text layer and proposing corrected values.
- Not for generating an entire scale of sizes at once, and not for spacing between elements (that's vertical rhythm, a different skill).
Inputs
font-size(px or rem).font-family-category: sans-serif / serif / display / monospace (serifs generally need more line-height breathing room than sans at the same size).text-role: body / heading / UI label / caption.text-case: original (sentence/title case) or uppercase/small-caps — this is the primary driver of letter-spacing direction, see Workflow step 2.x-height: high / low, if known for the specific typeface (see Workflow step 2b) — optional refinement, not required to produce a baseline recommendation.font-weight: numeric (100–900) if known — scales the tracking magnitude from step 2/2b, see Workflow step 2c.variable-font-axes: if the font is variable, the actualwghtvalue and whether anopsz(optical size) axis exists and is bound to size — see Workflow step 2d.- If available:
textLeadingTrim/ vertical-trim state (see Figma Node Integration) — this changes the recommended line-height, not just its visual effect.
Outputs
line-heightvalue (unitless ratio preferred for body text, so it scales with font-size).letter-spacingvalue (inem, since tracking should scale with font-size, not be a fixed px).- CSS/token snippet, plus a one-line rationale per value.
Workflow
- Base line-height by role and family:
- Body text:
1.4–1.65. Serif → upper half of range (more breathing room); sans-serif → lower half. - Headings: tighter,
1.1–1.3— large text needs proportionally less line-height. - UI labels / captions:
1.2–1.4, biased by legibility needs at small size rather than by family.
1b. Measure compensation ("Typography Triangle") — only when the column is wider than ideal: if line-length-optimizer reports (or the input states) a measure wider than the ~65-character ideal — the eye has more trouble tracking back to the start of the next line the wider the column gets — compensate by increasing line-height beyond the step-1 base:
`` LH_new = LH_base + ((characters_per_line − 65) / 100) × 0.1 ``
E.g. at 85 characters per line, line-height grows from 1.5 to 1.7. Only apply this when measure is confirmed wider than ~65–75 characters (i.e. already outside line-length-optimizer's comfortable range) — don't apply a correction for a column that's merely at the high end of normal. This is a compensation for a business constraint that's forcing an over-wide container, not a substitute for fixing the width via line-length-optimizer when that's actually available.
- Letter-spacing (tracking) — driven first by case, then by size.
text-caseis the primary branch — check it before applying any size-based rule:
Branch A — uppercase or small-caps (textCase: UPPERCASE / SMALL_CAPS, or CSS text-transform: uppercase): default font spacing is tuned for mixed-case letterforms, which lose their shape variety when flattened to all-caps — the eye can't recognize word-shapes anymore, only a dense block of equal-height letters. Always add positive tracking, scaled inversely with size (small caps text needs proportionally more room than large):
font-size ≤ 12px→letter-spacing: +10%(+0.10em)12px 18px→letter-spacing: +2%(+0.02em)
Branch B — sentence/title case (textCase: ORIGINAL): lowercase letters carry their own internal/external counters that already aid legibility — no tracking adjustment needed at body/reading sizes. Only tighten at larger sizes, where letter-spacing optically opens up and headings start to look "broken apart":
font-size < 24px→letter-spacing: 0(normal) — don't touch it.24px ≤ font-size < 36px, sans-serif →letter-spacing: -1.5%(-0.015em).font-size ≥ 36px, sans-serif →letter-spacing: -2%(-0.02em).- Serif/display faces: apply roughly half the sans-serif tightening — serifs already have less open counters, so the same negative value over-tightens.
2b. x-height refinement (optional, only if the specific typeface's x-height is known):
- High x-height (e.g. Inter, Roboto, San Francisco — most UI-oriented sans faces): open, wide counters at small sizes aid legibility, but the letters occupy more vertical space and tolerate — sometimes need — very slightly looser tracking at small sizes to avoid letters visually merging. Bias toward the upper end of the Branch A/B ranges above.
- Low x-height (classic serif and elegant display faces, e.g. Georgia-style proportions): small letters are compact relative to cap-height, leaving more natural vertical air already. Keep tracking closer to zero — loosening it further makes low-x-height letters visually drift apart and the word loses cohesion. Bias toward the lower end of the ranges above, or don't adjust at all if already near zero.
- If x-height is unknown, skip this refinement — the Branch A/B base values are safe defaults without it.
2c. Font-weight modifier (apply after Branch A/B + x-height, as a final scaling pass on whatever tracking value step 2/2b produced):
- Heavier weights (Bold/700, Black/900) carry thicker strokes, which already reduce the white space inside and between letters — the same numeric tracking value reads as looser on a Black weight than on a Regular weight, because there's less counter-space for the extra room to sit in. Reduce the computed tracking magnitude (keep the sign from Branch A/B, scale the number down):
- Weight ≥ 700 (Bold/Black/Heavy): multiply the Branch A/B result by roughly
×0.7. - Weight ≤ 300 (Light/Thin): multiply by roughly
×1.15— thin strokes leave more open counters already, and default spacing can look slightly cramped without a small boost. - Weight 400–500 (Regular/Medium): no modifier, use the Branch A/B value as-is.
- This applies in both branches: an uppercase Black-weight label still gets positive tracking (Branch A), just proportionally less than the same label in Regular weight.
- If weight is unknown, skip this modifier — Branch A/B values already assume Regular/Medium as the default case.
2d. Variable fonts — read the actual axis value, don't assume a named instance:
- A variable font's weight is a continuous value (e.g.
wght: 550), not a fixed Regular/Bold step. Apply the font-weight modifier (step 2c) by interpolating between the anchor points (400→×1, 700→×0.7, 300→×1.15) rather than snapping to the nearest named instance — awght: 550gets a modifier roughly halfway between ×1 and ×0.7. - If the font exposes an optical size axis (
opsz), check it before manually adjusting tracking at all:opszis specifically designed to auto-adjust spacing, stroke contrast, and x-height proportions for the rendering size, which is much of what steps 2/2b/2c are compensating for manually. Whenopszis bound tofont-size(CSSfont-optical-sizing: auto— the default in modern browsers — or the Figma equivalent), treat the manual tracking correction as a smaller supplementary nudge, not the full computed value, and say so in the output rather than silently stacking both corrections. - If no variation axis data is available (static font, or Figma reports only a named instance), fall back to the discrete Regular/Bold treatment in steps 2c and don't guess at intermediate values.
- Vertical-trim / leading-trim guard (critical — check before finalizing line-height):
- Default behavior (trim off): the font's built-in leading (space above cap-height and below baseline) is included in the line box. The
line-heightvalues above assume this default. - If vertical-trim /
leading-trimis on (CSSleading-trim: both/text-box-trim, or FigmatextLeadingTrim: CAP_HEIGHTor equivalent): the line box is cropped to cap-height/baseline, removing the font's built-in air. In this state, the same numericline-heightreads as visually tighter — recompute upward (roughly +10–15%) if trim is on and the target visual density should stay the same as the untrimmed baseline. State explicitly whether the recommended value assumes trim on or off — never hand over a bare number without saying which. - If unknown (plain CSS with no Figma/plugin context, no way to check), state the assumption ("assuming default leading, trim off") rather than silently picking one.
- Property name: this skill uses
textNode.textLeadingTrim(values"CAP_HEIGHT"/"NONE"), matching current Figma Plugin API. The shorterleadingTrimis a colloquial/CSS-adjacent shorthand (CSS itself usesleading-trim) — not the actual Figma API property name. UsetextLeadingTrimwhen writing to a node.
- OpenType number styles — pick by content role, not by default. Digits aren't one glyph set; the font exposes up to four figure styles along two independent axes (form × spacing), and picking wrong is a common, avoidable error:
| Content role | Figure form | Spacing | CSS | Figma fontFeatures | |---|---|---|---|---| | Running prose (articles, body copy) | Oldstyle (varying heights, ascenders/descenders — blends into lowercase text instead of forming bright "spikes") | Proportional | font-variant-numeric: oldstyle-nums proportional-nums; | { onum: 1 } | | Headings / display / UI counters (prices, single stats, non-tabular labels) | Lining (uniform cap-height) | Proportional (natural rhythm — a 1 shouldn't occupy the same width as an 8) | font-variant-numeric: lining-nums proportional-nums; | { lnum: 1 } | | Tables, financial statements, any column of stacked numbers | Lining | Tabular (fixed-width per digit, so units/tens/hundreds columns align vertically) | font-variant-numeric: lining-nums tabular-nums; | { lnum: 1, tnum: 1 } |
- Default to the prose row for body text and the heading row for display/UI unless the user specifies otherwise — don't leave figures at the font's raw default, which is often lining+proportional regardless of role and will look wrong in a paragraph of running text.
- Kerning interacts differently per spacing mode (ties into step 5): proportional figures (prose and heading rows) need
fontKerning: "METRIC"active — pairs like11or74produce visible gaps without it. Tabular figures have kerning intentionally suppressed by the font itself to preserve fixed-width column alignment — do not forcefontKerningchanges on a tabular-figure node to "fix" spacing; that's the font working as designed, not a bug. - Residual pair collisions in display headings: even with
fontKerning: METRICactive, a font with a sparse OpenType kerning table can still leave specific proportional-figure pairs too tight (11— the two vertical strokes nearly touch) or too loose (74— the diagonal and the crossbar leave a visible gap) at large display sizes, where the flaw becomes visible. If a display heading contains one of these known-risky pairs, apply a localsetRangeLetterSpacingon just that pair,±2%, rather than adjusting the whole node's tracking. Don't apply this pre-emptively — only when the specific pair is present and the size is large enough to make it visible (roughly ≥32px). - Currency and fractional amounts (e.g.
99.90 zł,$4.99): the cents/grosze portion is a common candidate for the OpenTypeNumerator/superscript feature (supsor a fraction featurefrac) to visually subordinate it to the whole-number part, matching classic price-tag typesetting. Only apply when the content is genuinely a price/currency amount, not a generic decimal (3.14, a percentage) — those stay as plain lining figures. Also check the decimal separator (./,) isn't visually colliding with a preceding0— a rare kerning gap in some fonts — and apply the same local range-tracking fix as above if it is. - Small caps (
font-variant: small-caps/ FigmaletterCase: SMALL_CAPS) only when explicitly requested — it's a stylistic choice, not a default correction.
- Kerning/tracking integrity guard (hard rule, not a recommendation):
- Kerning (per-pair optical correction baked into the font's OpenType tables, e.g.
AV,Ta,We) and tracking/letter-spacing(a uniform global offset applied to every letter) are complementary, not substitutes. Adjusting tracking in steps 2/2b/2c/2d must never disable or bypass the font's native kerning table. - CSS: leave
font-kerning: normal(the default) — never setfont-kerning: noneas a side effect of a tracking change. - Figma:
textNode.fontKerningshould stay"METRIC"(or"AUTO"if that's the file's existing convention) whenever tracking is modified. Do not set it to"NONE"— that discards the type designer's kerning pairs entirely, which is almost never the actual intent even when a user asks for "tighter spacing." If a request sounds like it wants kerning disabled, confirm rather than assume, since it's a rare, usually-unwanted operation. - The larger the tracking value or font-size, the more visible a missing kerning table becomes (mismatched pairs stand out more at heading sizes) — so this guard matters most exactly where steps 2/2c apply the largest adjustments, not just as a blanket rule.
- Report
fontKerningstate in the output whenever letter-spacing is changed, so a disabled kerning table isn't silently inherited from the node's prior state.
- Dash rendering in Figma (which character/spacing to use is
microtypographyRule 1c — this step is only the rendering layer on top of that decision):
- Whenever a text node contains
-,–, or—,fontKerningmust be"METRIC"/"AUTO"(per step 5) so the font's OpenType pair tables can prevent collisions with adjacent outward-leaning letterforms (V—,—A,1–9) — this is the same guard as step 5, just called out explicitly because dash collisions are the most visible failure case. - Display-size / all-caps em dash: at large sizes or inside an all-caps run (Branch A from step 2), an unspaced em dash can visually crowd its neighbors even with correct kerning. Prefer a small local tracking nudge over inserting an actual space — a real space would reopen the "should this have spaces" question Rule 1c already answered, and would break word-count/line-wrap assumptions elsewhere. Apply
+3%setRangeLetterSpacingacross the em dash and the single character immediately on each side of it, not the dash alone — tracking just the dash glyph itself doesn't change its distance to its neighbors (letter-spacing is applied between characters, not as padding on one glyph), so the range must include at least one adjacent character per side to actually open the gap. - Numeric ranges with en dash in display headings (e.g. a large "1995–2026"): verify the digits aren't visually touching the dash's arms. If they are (common in cheaper fonts lacking digit-to-dash kerning pairs), apply the same local range-tracking nudge rather than falling back to a spaced dash, since a numeric range must stay unspaced per
microtypographyRule 1c. - Hyphen height in all-caps (
case-sensitive forms): a default hyphen is vertically centered for lowercase text; in an all-caps run it can visually hang too low relative to the capital letters. Check whether the font's OpenTypecasefeature (case-sensitive forms — raises hyphens, dashes, parentheses, and similar punctuation to align w
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: monikazapisek
- Source: monikazapisek/design-engineering-playbook
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.