typeset

A typographic specification for printed documents — essays, letters, reports. Anything destined for paper or a PDF.

Each section below shows what an element must look like, the normative values it must hit, and the same values expressed in two engines. The specification is the source of truth; CSS and Typst are conformant implementations of it, not the definition. Point an engine — or an AI wiring one up — at this page and the output should match.

For a machine

  • spec.json — the normative source. 22 sections, 95 elements, 2 templates, every length absolute.
  • SPEC.md — the same thing as prose, generated from spec.json. Paste this into a model's context.
  • llms.txt — what to read first, and in what order.
Serif EB Garamond Sans Source Sans 3 Mono IBM Plex Mono Base 11pt / 1.45 Page A4, 25×22mm Spec 1.0.0 · 2026-08-28

01 — Foundations

Tokens & scale

One named value per decision. The scale is 11pt base on roughly a 1.2 ratio — calm enough that four heading levels fit inside it, so an engine needs no sizes of its own invention.

The ink is #1a1a1a, not black. Pure black blooms on a laser printer and glares on a backlit PDF.

How it must look

24pt Heading one
18pt Heading two
14pt Heading three
12pt Heading four
11pt Body text — EB Garamond
9.5pt Captions, footnotes, tables
8pt Labels and running heads
ink muted faint rule wash accent

fonts

embeddingFont files MUST be embedded in the output. A document that resolves its fonts at print time is not reproducible.

serif

rolebody text — the reading face
familyEB Garamond
licenseOFL
fallbacksIowan Old Style, Palatino, Georgia, serif
weights400, 600
stylesnormal, italic
featuresoldstyle figures, small caps, standard ligatures, kerning
noteSet no smaller than 11pt. Below that the strokes thin out on office laser output.

sans

roleheadings, tables, captions, labels
familySource Sans 3
licenseOFL
fallbacksHelvetica Neue, Helvetica, Arial, sans-serif
weights300, 600, 700
stylesnormal, italic
noteHumanist, so it sits with an old-style serif instead of fighting it.

mono

rolecode, listings, technical identifiers
familyIBM Plex Mono
licenseOFL
fallbacksSF Mono, Menlo, Consolas, monospace
weights400, 600
stylesnormal, italic
featuresligatures disabled
noteMonospaced faces run optically large beside a serif; every size below is already corrected for it.

scale

ratio1.2
base11pt

steps

xs8pt
sm9.5pt
base11pt
h412pt
h314pt
h218pt
h124pt

color

ink

hex#1a1a1a
rolebody text

ink muted

hex#5a5a5a
rolecaptions, attributions, secondary text

ink faint

hex#8a8a8a
rolelabels, list markers, running heads

rule

hex#c9c4bd
rolehairlines, table body rules

rule strong

hex#6f6a64
roletable header and footer rules, block accents

wash

hex#f4f1ec
rolecode and callout backgrounds

accent

hex#7a1f1f
roledrop caps, note markers, fleurons
noteOxblood. Chosen to read as a dark grey when printed in greyscale.
.typeset {
        /* Families */
        --ts-serif: "EB Garamond", "Iowan Old Style", Palatino, Georgia, serif;
        --ts-sans: "Source Sans 3", "Helvetica Neue", Helvetica, Arial, sans-serif;
        --ts-mono: "IBM Plex Mono", "SF Mono", Menlo, Consolas, monospace;

        /* Scale — 11pt base, ~1.2 ratio */
        --ts-xs: 8pt;
        --ts-sm: 9.5pt;
        --ts-base: 11pt;
        --ts-h4: 12pt;
        --ts-h3: 14pt;
        --ts-h2: 18pt;
        --ts-h1: 24pt;

        /* Rhythm */
        --ts-leading: 1.45;
        --ts-leading-tight: 1.2;
        --ts-space: 11pt;          /* one line of base leading, near enough */
        --ts-measure: 33em;        /* ~66 characters at 11pt serif */

        /* Ink — never pure black: it blooms on laser and glares in a PDF */
        --ts-ink: #1a1a1a;
        --ts-ink-muted: #5a5a5a;
        --ts-ink-faint: #8a8a8a;
        --ts-rule: #c9c4bd;
        --ts-rule-strong: #6f6a64;
        --ts-wash: #f4f1ec;
        --ts-accent: #7a1f1f;      /* oxblood: reads as dark grey in greyscale */
      }
#let ink = rgb("#1a1a1a")
      #let ink-muted = rgb("#5a5a5a")
      #let ink-faint = rgb("#8a8a8a")
      #let rule-color = rgb("#c9c4bd")
      #let rule-strong = rgb("#6f6a64")
      #let wash = rgb("#f4f1ec")
      #let accent = rgb("#7a1f1f")

      #let serif = "EB Garamond"
      #let sans = "Source Sans 3"
      #let mono = "IBM Plex Mono"

      // A template supplies one of these. Every size in the document comes from it,
      // so nothing downstream has to know which template is in force.
      #let scale-single-column = (
        xs: 8pt, sm: 9.5pt, base: 11pt,
        h4: 12pt, h3: 14pt, h2: 18pt, h1: 24pt,
        leading: 1.45, space: 11pt,
      )

      // Two columns: every step comes down. A 77mm column carries 40 characters at
      // 11pt — below the 45-character floor — so the base drops to 9.5pt for 47.
      #let scale-two-column = (
        xs: 7pt, sm: 8.5pt, base: 9.5pt,
        h4: 9.5pt, h3: 11pt, h2: 13pt, h1: 20pt,
        leading: 1.4, space: 9.5pt,
      )

      // Defaults, for the standalone helpers below.
      #let base-size = scale-single-column.base
      #let sm = scale-single-column.sm
      #let xs = scale-single-column.xs

      // One unit of vertical space: 11pt, one line of base leading.
      #let sp = 11pt

      // The spec states line height as a baseline-to-baseline advance. Typst measures
      // leading between line boxes, so pinning the box to the band from the baseline
      // up to 1em makes the advance font-independent:
      //   advance = top-edge - bottom-edge + leading = 1em + leading
      // so leading = (line_height - 1) em. 1.45 → 0.45em → 15.95pt at 11pt.
      #let leading-for(line-height) = (line-height - 1) * 1em

      #let oldstyle = (number-type: "old-style", number-width: "proportional")
      #let lining = (number-type: "lining", number-width: "proportional")
      #let tabular = (number-type: "lining", number-width: "tabular")

02 — Foundations

Measure & rhythm

The single largest legibility lever in a printed document. A line longer than about 75 characters loses the reader on the return sweep; shorter than 45 and the eye jumps too often. 33em at 11pt lands near 66.

The measure constrains the text column, not the page. A table or a figure may exceed it; a paragraph never may.

How it must look

Typography exists to honour content. A page set to a comfortable measure asks nothing of the reader; they simply arrive at the end having forgotten there was a page at all. This paragraph is set to the default 33em.

And this one is deliberately overset to 52em, so you can feel the return sweep failing. By the third line your eye has to hunt for the start of the next, and the hunt is what tires a reader out over twenty pages.

rhythm

line height1.45
line height tight1.2
baseline advance15.95pt
space11pt
space noteOne unit of vertical space is 11pt — one line of base leading, near enough. Every gap below is a multiple of it.
measure33em
measure mm126
measure chars66
measure noteThe single largest legibility lever. Past about 75 characters the return sweep fails and readers re-read lines without noticing; below 45 the eye fixates too often.

measure variants

narrow27em
wide40em
.typeset {
        font-family: var(--ts-serif);
        font-size: var(--ts-base);
        font-variant-numeric: oldstyle-nums proportional-nums;
        line-height: var(--ts-leading);
        color: var(--ts-ink);
        text-rendering: optimizeLegibility;
        font-kerning: normal;
        font-feature-settings: "liga" 1, "kern" 1;
      }

      /* Measure is a property of the text column, not the page. */
      .typeset > * {
        max-width: var(--ts-measure);
      }

      .typeset--wide { --ts-measure: 40em; }
      .typeset--narrow { --ts-measure: 27em; }
set text(
          font: serif,
          size: scale.base,
          fill: ink,
          top-edge: 1em,
          bottom-edge: 0pt,
          ..oldstyle,
        )

        set par(
          leading: leading-for(scale.leading),
          spacing: if indented { leading-for(scale.leading) } else { sp },
          justify: justified,
          first-line-indent: if indented { (amount: 1.5em, all: false) } else { 0pt },

03 — Foundations

Page setup

A4 at 25×22mm, with mirrored margins so a duplex-printed document keeps its gutter. The running head is the current level-2 heading, resolved by the engine — never typed by the author, and never allowed to drift from the section it names.

Engine capability. Running heads and folios require the engine to write into the page margin. Typst and LaTeX do this natively. In CSS it is Paged Media, which no browser implements — Chrome's print dialog ignores it and substitutes its own header and footer, so the CSS implementation needs Paged.js, WeasyPrint or Prince. The two CSS examples do exactly that.

How it must look

Measure & rhythm
7
25mm
A4 · 22mm outer / 28mm inner

page

sizeA4
size mm210, 297
alternate sizeLetter
margin top mm25
margin bottom mm25
margin outer mm22
margin inner mm28
duplexMargins mirror: the inner (binding) margin is 28mm on both left and right pages, so the gutter stays put when printed double-sided.
text width mm160

running head

positiontop centre
contentthe current level-2 heading, uppercased
fontsans
size8pt
tracking0.08em
colorink_faint
suppressed onfirst page

folio

positionbottom centre
contentpage number
fontserif
size9pt
numeralsoldstyle
colorink_muted
suppressed onfirst page
@page {
        size: A4;                        /* or: Letter */
        margin: 25mm 22mm 25mm 22mm;

        /* Margin boxes need a paged-media engine: Paged.js, WeasyPrint, Prince.
           Chrome's own print dialog ignores them. */
        @top-center {
          content: string(ts-running-head);
          font: 8pt/1 "Source Sans 3", sans-serif;
          letter-spacing: 0.08em;
          text-transform: uppercase;
          color: #8a8a8a;
        }
        @bottom-center {
          content: counter(page);
          font: 9pt/1 "EB Garamond", serif;
          font-variant-numeric: oldstyle-nums;
          color: #5a5a5a;
        }
      }

      /* No running head or folio on the opening page. */
      @page :first {
        @top-center { content: none; }
        @bottom-center { content: none; }
      }

      /* Duplex binding: mirror the inner margin so the gutter stays put. */
      @page :left  { margin-left: 22mm; margin-right: 28mm; }
      @page :right { margin-left: 28mm; margin-right: 22mm; }

      /* The running head follows the current section. */
      .typeset h2 { string-set: ts-running-head content(text); }
set page(
          paper: "a4",
          margin: (top: 25mm, bottom: 25mm, inside: 28mm, outside: 22mm),
          header: context {
            // The running head follows the current level-2 heading, and is
            // suppressed on the opening page.
            if running-head and here().page() > 1 {
              let seen = query(selector(heading.where(level: 2)).before(here()))
              if seen.len() > 0 {
                set text(font: sans, size: xs, fill: ink-faint, tracking: 0.08em)
                align(center, upper(seen.last().body))
              }
            }
          },
          footer: context {
            if folio and here().page() > 1 {
              set text(font: serif, size: 9pt, fill: ink-muted, ..oldstyle)
              align(center, counter(page).display())
            }
          },

04 — Templates

Two column, equal

For papers, journal articles, newsletters and technical notes — documents scanned and referenced as much as read. A template sets the page and the scale; it does not get its own typography, so everything else on this page still applies unchanged.

The base size drops to 9.5pt here, and that is arithmetic rather than taste. A4 less the 28mm inner and 22mm outer margin leaves 160mm; a 6mm gutter divides it into two 77mm columns. At 11pt a 77mm column carries 40 characters — below the 45-character floor this spec sets for prose. 9.5pt restores 47, and every other step comes down with it.

Two consequences worth stating: justification stops being optional, because at 47 characters a ragged edge serrates the column visibly; and sidenotes are forbidden, because there is no margin left to put them in.

Engine capability. A column container cannot be fragmented by Paged.js — it moves content between page boxes and cannot split a column flow, so it produces zero pages. The CSS two-column example therefore prints directly from the browser and simulates one sheet on screen. Typst paginates columns natively.

How it must look

The Measure of a Column

What changes when a page is divided in two, and what must not

Abstract Dividing an A4 page into two equal columns leaves 77mm of measure. At the single-column base of 11pt that carries 40 characters — below the floor at which a line of prose stays readable.

The arithmetic

A4 is 210mm wide. Take off the 28mm inner margin and the 22mm outer margin and 160mm of text width remains. A 6mm gutter divides it into two columns of 77mm.

At 11pt, a 77mm column carries about 40 characters. This specification sets 45 as the floor for a line of prose, so 11pt is not available here. Dropping the base to 9.5pt restores 47 characters.

What follows

Leading tightens from 1.45 to 1.4. A shorter line needs less vertical separation for the return sweep to stay unambiguous, and the saving is real: at 13.3pt of advance a column holds 52 lines instead of 47.

Headings come down hardest. A 24pt level-2 heading inside a 77mm column spends three lines saying two words, so the scale runs 20/13/11/9.5.

What does not change

The palette, the rule weights, the numeral conventions and the pagination rules are all identical.

Justification

At 47 characters a ragged right edge serrates the column visibly, and the word gaps in an unhyphenated justified column read as rivers. Both faults are tolerable at 66 characters and neither is tolerable here.

The one place the spec removes a choice.

So the template turns justification on itself rather than offering it as an option.

Table 1 — Characters per line for a 77mm column.
Base11pt10pt9.5pt9pt
Characters40444749
Conformantnonoyesyes

What the template forbids

Sidenotes have nowhere to go: there is no margin left. They become footnotes or endnotes, and an implementation should degrade them visibly rather than positioning them off the page and losing them.

A three-line drop cap at 3.05em is 29pt — a quarter of the column width for one letter. The cap is neutralised and the opening words carry the signal in small caps instead.

Spanning

An element spans both columns fully or neither. Spanning costs a break in both columns, so it is opt-in per instance and belongs at the top or the bottom of a page.

Set in EB Garamond, 9.5 on 13.3 points, two columns of 77mm with a 6mm gutter.

A4 at 42%. Open the full document →

Opt-in · typeset--two-column

Use for — Papers, journal articles, newsletters, technical notes — documents that are scanned and referenced as much as read, and where a figure is worth more than an uninterrupted argument.

derivation

text width mm160
column gap mm6
column width mm77
floor45
conclusionA 77mm column carries 40 characters at the 11pt base — below the 45-character floor this spec sets for a line of prose. The base size therefore DROPS to 9.5pt, which restores 47. This is arithmetic, not preference: an implementation that keeps 11pt in two columns violates the measure rule, which is the rule everything else in this spec is downstream of.
recompute whenThe page size, the margins or the gutter change. Characters per line = 0.524 x column_mm x (11 / base_pt).

characters per line

11pt40
10.5pt42
10pt44
9.5pt47
9pt49

requirements

  • Justification with hyphenation is MANDATORY, not optional. At 47 characters a ragged right edge produces a visibly serrated column and word gaps wide enough to read as rivers. This is the one place the spec removes a choice it otherwise offers.
  • The last line of a paragraph is still flush left. Two columns make a stretched last line more visible, not less.
  • Balance the columns on the final page. A last page with one full column and one empty third reads as a printing error.
  • A spanning element must span BOTH columns fully or neither. An element that spans one and a half columns has no correct reading order.
  • Keep a spanning element at the top or the bottom of the page, never mid-column. Interrupting both columns in the middle forces the reader to find their place twice.

page

sizeA4
margin top mm25
margin bottom mm25
margin outer mm22
margin inner mm28
columns2
column widthsequal — the two columns are the same width, always
column gap mm6
column width mm77
column rulenone by default; an optional 0.5pt hairline in `rule` where the columns need separating
column balancethe final page balances its columns to equal height
lines per column52
running headas foundation.page — spans the full text width, not a column
folioas foundation.page

scale

ratio1.2
base9.5pt
noteEvery step comes down. A 24pt heading inside a 77mm column takes three lines to say two words; 18pt takes two. The h1 keeps display size because it spans both columns.

steps

xs7pt
sm8.5pt
base9.5pt
h49.5pt
h311pt
h213pt
h120pt

rhythm

line height1.4
baseline advance13.3pt
space9.5pt
measurethe column — 77mm, not a character count
measure chars47
noteLeading tightens with the measure: a shorter line needs less vertical separation to keep the return sweep unambiguous.

spanning

alwaystitle block, subtitle, byline, dateline, abstract, heading 1, colophon, bibliography heading
optionalfigure, table, code block, pull quote
neverparagraph, list, blockquote, callout, heading 2 and below, endnotes
noteA wide figure or table opts in per instance. The default is column-width, because a spanning element costs a break in both columns.

forbidden

sidenoteThere is no margin to put it in. Use a footnote or an endnote.
dropcapA three-line cap at 3.05em is 29pt in a 77mm column — a quarter of the column width for one letter. Open with small caps instead.
measure variantsnarrow and wide are meaningless: the column is the measure.

element overrides

h1

size20pt
spansboth columns
space after9.5pt

h2

size13pt
space before19pt
space after4.75pt

h3

size11pt
space before19pt
space after4.75pt

h4

size9.5pt
space before19pt
space after4.75pt

paragraph

size9.5pt
line height1.4
space after0
first line indent1.25em
noteIndented paragraphs are the default here, not an option: a blank line costs 3% of a column.

blockquote

size9pt
indent left9.5pt

table

size8pt
line height1.3

codeblock

size7.5pt
line height1.4

figure caption

size8pt

footnote

size8pt
noteScoped to the page, not the column: one notes area spanning both columns at the page foot.

endnotes

size8.5pt

pullquote

size12pt
noteColumn width by default. Spanning both columns turns it into a divider, which is a different and heavier thing.

abstract

size9pt
max widththe full text width, set apart above the columns
/* Papers, journal articles, newsletters, technical notes — documents scanned
         and referenced as much as read.
      
         The base size drops to 9.5pt here, and that is arithmetic rather than taste:
         a 77mm column carries 40 characters at 11pt, below the 45-character floor
         this stylesheet sets for a line of prose. 9.5pt restores 47. An
         implementation that keeps 11pt in two columns breaks the measure rule, which
         is the rule everything else is downstream of. */
      .typeset--two-column {
        /* Every step comes down. A 24pt heading in a 77mm column spends three lines
           saying two words. */
        --ts-xs: 7pt;
        --ts-sm: 8.5pt;
        --ts-base: 9.5pt;
        --ts-h4: 9.5pt;
        --ts-h3: 11pt;
        --ts-h2: 13pt;
        --ts-h1: 20pt;

        /* Leading tightens with the measure: a shorter line needs less separation to
           keep the return sweep unambiguous. */
        --ts-leading: 1.4;
        --ts-space: 9.5pt;

        /* The column IS the measure, so the character cap comes off. */
        --ts-measure: none;
        --ts-column-gap: 6mm;

        /* A blank line costs 3% of a column, so paragraphs indent here by default —
           not as an option. */
        --ts-para-gap: 0;
        --ts-para-indent: 1.25em;

        font-size: var(--ts-base);
        columns: 2;
        column-gap: var(--ts-column-gap);
        column-fill: balance;   /* the final page balances its columns */

        /* Justification with hyphenation is MANDATORY at this measure, not optional:
           at 47 characters a ragged edge serrates the column and the word gaps read
           as rivers. This is the one place the stylesheet removes a choice. */
        text-align: justify;
        text-align-last: left;
        hyphens: auto;
        hyphenate-limit-chars: 6 3 3;
        hyphenate-limit-lines: 2;
      }

      /* Spans both columns, always. Each is a direct child of the multicol container,
         which is what column-span requires. */
      .typeset--two-column > .ts-titleblock,
      .typeset--two-column > .ts-abstract,
      .typeset--two-column > h1,
      .typeset--two-column > .ts-colophon,
      .typeset--two-column > .ts-span {
        column-span: all;
      }

      /* Spanning is opt-in per instance for figures and tables: it costs a break in
         both columns, so the default stays column-width. A spanning element must sit
         at the top or the bottom of the page, never mid-column. */
      .typeset--two-column > .ts-span {
        break-before: avoid-column;
        margin-bottom: calc(var(--ts-space) * 1.5);
      }

      /* An optional hairline where the columns need separating. Most journals omit
         it — the gutter is already doing the work. */
      .typeset--two-column.typeset--column-rule {
        column-rule: 0.5pt solid var(--ts-rule);
      }

      /* A sidenote has no margin to live in here. Rather than let it be positioned
         off the page and silently lost, it degrades to an inline aside. */
      .typeset--two-column .ts-sidenote {
        position: static;
        display: block;
        width: auto;
        margin: 0.4em 0;
        padding-left: 0.6em;
        border-left: 0.5pt solid var(--ts-rule);
        text-indent: 0;
      }

      /* A three-line cap at 3.05em is 29pt — a quarter of the column width for one
         letter. The opening small caps carry the signal instead. */
      .typeset--two-column .ts-dropcap::first-letter {
        float: none;
        font-size: inherit;
        line-height: inherit;
        color: inherit;
        padding: 0;
      }

      /* Denser matter for a narrower column. */
      .typeset--two-column blockquote { font-size: 9pt; padding-left: var(--ts-space); }
      .typeset--two-column table { font-size: 8pt; line-height: 1.3; }
      .typeset--two-column pre { font-size: 7.5pt; }
      .typeset--two-column figcaption { font-size: 8pt; }
      .typeset--two-column .ts-endnotes { font-size: 8.5pt; }
      .typeset--two-column .ts-pullquote { font-size: 12pt; }
      .typeset--two-column .ts-abstract { font-size: 9pt; max-width: none; }
      .typeset--two-column .ts-epigraph { max-width: none; }
// Two columns, equal. Papers, journal articles, newsletters, technical notes.
      //
      // `front` is set full width before the columns begin — a title block, an
      // abstract, a level-1 heading. Everything in `doc` flows in two columns.
      //
      // Typst's own `page(columns: 2)` fixes the gutter at 4% of the page width
      // (8.4mm on A4). The spec says 6mm, so the body goes through `columns()`
      // instead, which takes an explicit gutter and still breaks across pages.
      #let two-column(
        front: none,
        column-rule: false,
        justified: true,
        numbered: false,
        running-head: true,
        folio: true,
        doc,
      ) = {
        // Justification is not optional at a 47-character measure.
        assert(justified, message: "two-column requires justification: at 47 characters a ragged edge serrates the column")

        show: typeset.with(
          scale: scale-two-column,
          measure: none,        // the column is the measure
          justified: true,
          indented: true,      // a blank line costs 3% of a column
          numbered: numbered,
          running-head: running-head,
          folio: folio,
        )

        if front != none {
          front
          v(scale-two-column.space, weak: true)
        }

        // A hairline where the columns need separating. Most journals omit it — the
        // gutter is already doing the work. `columns()` has no rule of its own, so it
        // is drawn on the page behind the text, at the centre of the gutter. The
        // offset flips with page parity because the margins mirror for duplex.
        if column-rule {
          set page(background: context {
            let inner = if calc.odd(here().page()) { 28mm } else { 22mm }
            place(
              top + left,
              dx: inner + 77mm + 3mm,
              dy: 25mm,
              line(angle: 90deg, length: 247mm, stroke: 0.5pt + rule-color),
            )
          })
        }

        columns(2, gutter: 6mm, doc)
      }

      // A level-1 heading spans both columns, which in Typst means it must be a
      // parent-scoped float. That works in `front`, before the columns begin. A
      // level-1 heading in the BODY has to be wrapped in `span()` explicitly —
      // Typst cannot promote it out of the column flow on its own. In a paper the
      // body's section headings are level 2 anyway; level 1 is the title.
      //
      // Spans both columns. A spanning element costs a break in both, so it is opt-in
      // per instance and must sit at the top or the bottom of the page — never
      // mid-column, which makes the reader find their place twice.
      #let span(body, at-bottom: false) = place(
        if at-bottom { bottom } else { top },
        scope: "parent",
        float: true,
        block(width: 100%, body),
      )

05 — Structure

Headings

Sans against the serif body, so hierarchy reads by contrast rather than by size alone. Below level 4 the size stops changing and the register shifts instead: small caps, then italic. Six visually distinct levels inside a 24pt–11pt range.

Two non-negotiable rules: the space above is four times the space below, because a heading belongs to what follows it; and a heading must never be the last thing on a page. Whatever the engine calls that — break-after: avoid, sticky, \needspace — it is mandatory, not a preference.

How it must look

The Elements

Body text follows, set flush.

Composition

A section opens here, and the running head of the page changes to match it.

The measure

A subsection, still clearly subordinate.

Choosing a ratio

Fourth level, the last with a size change.

On old-style figures

Fifth level: small caps at body size.

A closing remark

Sixth level: italic at body size.

Run-in

a heading that shares the first line of its paragraph, which saves vertical space in a dense report.

  • Sans against the serif body, so hierarchy reads by contrast rather than by size alone.
  • Below level 4 the size stops changing and the register shifts instead — small caps, then italic. Six distinct levels inside a 24pt–11pt range.
  • Space above is four times the space below: a heading belongs to what follows it.
  • A heading MUST NOT be the last thing on a page. This is the most common defect in printed documents.

Heading 1 — document title

fontsans
weight300
size24pt
line height1.2
baseline advance28.8pt
tracking-0.015em
colorink
alignleft
space before0
space after5.5pt
break afteravoid
break insideavoid
wrapbalanced

Heading 2 — section

fontsans
weight600
size18pt
line height1.2
baseline advance21.6pt
tracking-0.01em
colorink
alignleft
space before22pt
space after5.5pt
break afteravoid
break insideavoid
wrapbalanced
sets running headyes

Heading 3 — subsection

fontsans
weight600
size14pt
line height1.2
baseline advance16.8pt
colorink
alignleft
space before22pt
space after5.5pt
break afteravoid

Heading 4

fontsans
weight600
size12pt
line height1.2
baseline advance14.4pt
colorink
alignleft
space before22pt
space after5.5pt
break afteravoid

Heading 5

fontserif
weight600
size11pt
variantall small caps
tracking0.06em
colorink
space before22pt
space after5.5pt
break afteravoid

No size change from body — the register carries the level.

Heading 6

fontserif
weight400
styleitalic
size11pt
colorink
space before22pt
space after5.5pt
break afteravoid

Run-in heading

displayinline with the first line of its paragraph
separator ·
separator colorink_faint
space before0
space after0

For dense reports where a full heading line is too much vertical cost.

.typeset h1,
      .typeset h2,
      .typeset h3,
      .typeset h4,
      .typeset h5,
      .typeset h6 {
        font-family: var(--ts-sans);
        font-weight: 600;
        line-height: var(--ts-leading-tight);
        color: var(--ts-ink);

        /* A heading belongs to what follows it, not to what precedes it. */
        margin: calc(var(--ts-space) * 2) 0 calc(var(--ts-space) * 0.5);

        /* Never strand a heading at the foot of a page. */
        break-after: avoid;
        break-inside: avoid;
        text-wrap: balance;
      }

      .typeset h1 {
        font-size: var(--ts-h1);
        font-weight: 300;
        letter-spacing: -0.015em;
        margin-top: 0;
      }

      .typeset h2 {
        font-size: var(--ts-h2);
        letter-spacing: -0.01em;
      }

      .typeset h3 { font-size: var(--ts-h3); }

      .typeset h4 { font-size: var(--ts-h4); }

      /* Below h4, drop the size change and shift register instead. */
      .typeset h5 {
        font-size: var(--ts-base);
        font-family: var(--ts-serif);
        font-weight: 600;
        font-variant-caps: all-small-caps;
        letter-spacing: 0.06em;
      }

      .typeset h6 {
        font-size: var(--ts-base);
        font-family: var(--ts-serif);
        font-weight: 400;
        font-style: italic;
      }

      /* A run-in heading: the h6 sits on the first line of its paragraph. */
      .typeset .ts-run-in {
        display: inline;
        margin: 0;
      }
      .typeset .ts-run-in + p { display: inline; }
      .typeset .ts-run-in::after { content: " · "; color: var(--ts-ink-faint); }
show heading: set text(font: sans, hyphenate: false)
        show heading: set par(justify: false, first-line-indent: 0pt)
        show heading: it => block(
          above: sp * 2,
          below: sp * 0.5,
          sticky: true,
          breakable: false,
          it,
        )

        show heading.where(level: 1): set text(size: scale.h1, weight: 300, tracking: -0.015em)
        show heading.where(level: 1): it => block(above: 0pt, below: sp * 0.5, sticky: true, it)
        show heading.where(level: 2): set text(size: scale.h2, weight: 600, tracking: -0.01em)
        show heading.where(level: 3): set text(size: scale.h3, weight: 600)
        show heading.where(level: 4): set text(size: scale.h4, weight: 600)
        show heading.where(level: 5): set text(
          font: serif, size: scale.base, weight: 600, tracking: 0.06em, ..smcp,
        )
        show heading.where(level: 6): set text(

06 — Structure

Section numbering

Opt-in. Numbers are generated by a counter, never typed, so they cannot drift out of sync when a section moves — and they disappear cleanly when the option comes off, which matters because an essay wants no numbers and a report wants them.

How it must look

Scope

The opening section.

Out of scope

A subsection, numbered from its parent.

Assumptions

The second subsection.

Method

The counter for subsections resets here.

Instrumentation

Numbered 2.1, without anyone typing “2.1”.

Opt-in · typeset--numbered

  • Numbers are generated by a counter, never typed. A typed number drifts the moment a section moves.
  • Reports are numbered; essays and letters are not. It must be one switch.

Level-2 number

format1
separatortwo spaces
colorink_faint
numeralslining tabular
resetsthe level-3 counter

Level-3 number

format1.1
separatortwo spaces
colorink_faint
numeralslining tabular
.typeset--numbered { counter-reset: ts-h2; }

      .typeset--numbered h2 {
        counter-increment: ts-h2;
        counter-reset: ts-h3;
      }
      .typeset--numbered h2::before {
        content: counter(ts-h2) "  ";
        color: var(--ts-ink-faint);
        font-variant-numeric: lining-nums tabular-nums;
      }

      .typeset--numbered h3 { counter-increment: ts-h3; }
      .typeset--numbered h3::before {
        content: counter(ts-h2) "." counter(ts-h3) "  ";
        color: var(--ts-ink-faint);
        font-variant-numeric: lining-nums tabular-nums;
      }

Set globally rather than per element — this is the “headings” region of typeset.typ.

show heading: set text(font: sans, hyphenate: false)
        show heading: set par(justify: false, first-line-indent: 0pt)
        show heading: it => block(
          above: sp * 2,
          below: sp * 0.5,
          sticky: true,
          breakable: false,
          it,
        )

        show heading.where(level: 1): set text(size: scale.h1, weight: 300, tracking: -0.015em)
        show heading.where(level: 1): it => block(above: 0pt, below: sp * 0.5, sticky: true, it)
        show heading.where(level: 2): set text(size: scale.h2, weight: 600, tracking: -0.01em)
        show heading.where(level: 3): set text(size: scale.h3, weight: 600)
        show heading.where(level: 4): set text(size: scale.h4, weight: 600)
        show heading.where(level: 5): set text(
          font: serif, size: scale.base, weight: 600, tracking: 0.06em, ..smcp,
        )
        show heading.where(level: 6): set text(

07 — Prose

Paragraphs

Two conventions, one switch. Spaced paragraphs are the default and suit reports and letters. Indented paragraphs with no gap are the book convention and read better in continuous prose; the first paragraph after any heading, block or break stays flush left in both.

Orphan and widow control set to 2: never a single line left behind at a page foot, never one carried alone to the next head.

Rendered — spaced

The first duty of a paragraph is to be findable. Spaced paragraphs announce themselves with white space, which is why they suit documents that get skimmed: memos, reports, letters.

The cost is vertical space, and a certain restlessness over many pages. Every gap is a small invitation to stop reading.

Rendered — indented

On continuous prose

The first paragraph after a heading needs no indent: the heading has already marked the start. So it sits flush.

Subsequent paragraphs indent by 1.5em, with no gap. The page becomes a continuous field of text, which is exactly what you want when the reader is meant to read every word rather than hunt for one.

This is the convention of nearly every printed book you own, and it is almost never seen on the web — which is the clearest sign that print and screen are different media.

  • Two conventions, one switch. Spaced paragraphs suit documents that get skimmed. Indented paragraphs with no gap suit continuous prose and are the convention of nearly every printed book.
  • The first paragraph after any heading, block or break is flush left: the thing above it has already marked the start.

Paragraph — spaced (default)

fontserif
weight400
size11pt
line height1.45
baseline advance15.95pt
numeralsoldstyle proportional
colorink
space after11pt
first line indent0
orphans2
widows2
max width33em

Paragraph — indented opt-in · typeset--indented

space after0
first line indent1.5em
first line indent after block0

Applies after a heading, blockquote, figure or section break, and to the first paragraph of the document.

.typeset {
        /* Paragraph separation is one decision expressed as two values: a gap, or an
           indent, never both. A template sets these; nothing else needs to know. */
        --ts-para-gap: var(--ts-space);
        --ts-para-indent: 0;
      }

      .typeset--indented {
        --ts-para-gap: 0;
        --ts-para-indent: 1.5em;
      }

      .typeset p {
        margin: 0 0 var(--ts-para-gap);
        text-indent: var(--ts-para-indent);
        orphans: 2;   /* no single line left at the foot of a page */
        widows: 2;    /* no single line carried to the head of the next */
      }

      /* The first paragraph after a heading, block or break is flush: the thing above
         it has already marked the start.
      
         Enumerated rather than written as `:is(...)`. Paged.js rewrites every
         stylesheet through its own CSS parser, which does not understand `:is()`: it
         splits the selector on the commas INSIDE the argument list and emits fragments
         like ` .ts-callout)+p`, which throw on querySelectorAll and abort pagination
         entirely — a blank document, not a degraded one. Keep this list flat. */
      .typeset h1 + p,
      .typeset h2 + p,
      .typeset h3 + p,
      .typeset h4 + p,
      .typeset h5 + p,
      .typeset h6 + p,
      .typeset blockquote + p,
      .typeset figure + p,
      .typeset pre + p,
      .typeset table + p,
      .typeset .ts-break + p,
      .typeset .ts-callout + p,
      .typeset > p:first-child {
        text-indent: 0;
      }

Set globally rather than per element — this is the “foundation” region of typeset.typ.

set text(
          font: serif,
          size: scale.base,
          fill: ink,
          top-edge: 1em,
          bottom-edge: 0pt,
          ..oldstyle,
        )

        set par(
          leading: leading-for(scale.leading),
          spacing: if indented { leading-for(scale.leading) } else { sp },
          justify: justified,
          first-line-indent: if indented { (amount: 1.5em, all: false) } else { 0pt },

08 — Prose

Alignment: ragged or justified

Ragged right is the default, and it is a choice rather than an absence. Justification buys a clean right edge and pays for it in uneven word spacing; a ragged setting keeps the spacing even and gives up the edge. Neither is more correct — but the cost lands differently depending on who the document is addressed to.

Ragged right for a letter, a memorandum, a short note: anything addressed to a person rather than to a readership. Justification for continuous prose at a full measure: an essay, a report, a paper. It is mandatory in two columns, where a ragged edge at 47 characters serrates the column.

Justification and hyphenation are a single decision. Justified text without hyphenation opens rivers of white space; hyphenation without justification breaks words for no gain. Take both or neither — and hyphenation needs the document language declared, since an English dictionary applied to Portuguese produces confident nonsense.

Two traps, both learned the hard way on this stylesheet. text-align inherits, so setting it on the document container justifies the subtitle, the byline, the captions and the address blocks too — every block that must stay flush declares its own text-align: left rather than relying on an exception list here. And text-align-last: left has to be explicit: under a paginating engine the fragmented text no longer looks like the end of a paragraph, and every last line gets stretched.

Ragged right — the default

Even word spacing throughout. The right edge is uneven, and the rag itself is what to judge: an even rag reads as deliberate.

Typographical craftsmanship consists in the appropriate use of the incomparable resources of a well-designed typeface, and in the discipline required to leave those resources alone when the content does not need them. Justification is one such resource: expensive, occasionally beautiful, and demanding of the hyphenation dictionary that makes it possible.

Justified, with hyphenation

A clean right edge. The word spacing now varies line by line, and hyphens appear to keep that variation small.

Typographical craftsmanship consists in the appropriate use of the incomparable resources of a well-designed typeface, and in the discipline required to leave those resources alone when the content does not need them. Justification is one such resource: expensive, occasionally beautiful, and demanding of the hyphenation dictionary that makes it possible.

Justified, no hyphenation — the mistake

Neither edge nor spacing. With no hyphens to absorb the slack, the gaps open into rivers running down the page.

Typographical craftsmanship consists in the appropriate use of the incomparable resources of a well-designed typeface, and in the discipline required to leave those resources alone when the content does not need them. Justification is one such resource: expensive, occasionally beautiful, and demanding of the hyphenation dictionary that makes it possible.

Opt-in · typeset--justified

  • Ragged right is the DEFAULT, and it is a choice rather than an absence — a document states it. Justification buys a clean right edge at the cost of uneven word spacing; a ragged setting keeps the spacing even and gives up the edge. Neither is more correct, but the cost lands differently by document.
  • Choose ragged right for a letter, a memorandum, a short note — anything addressed to a person rather than to a readership. Justification reads as institutional, and its even edge is the visual signature of print that was set for strangers.
  • Choose justification for continuous prose at a full measure: an essay, a report, a paper. It is mandatory in two columns, where a ragged edge at 47 characters serrates the column.
  • These are a single decision. Justification without hyphenation opens rivers of white space; hyphenation without justification breaks words for no gain. Take both or neither.
  • Hyphenation is per-language and requires the document language to be declared. An English dictionary applied to Portuguese produces confident nonsense.
  • The last line of a paragraph is NEVER stretched. State this explicitly — a paginating engine fragments the text, so the visual last line stops looking like the end of a paragraph and gets justified. It does not reproduce in an unpaginated preview.
  • Justification applies to prose only. It MUST NOT reach a subtitle, byline, caption, address block, table cell, heading or listing. In an engine where alignment inherits, every such block declares its own alignment rather than relying on an exception list.
  • Alignment inherits, and so does last-line alignment. A block that sets its own alignment — a centred pull quote, a centred section break — must also set its own last-line alignment, or the document's justification flushes its last line (its only line, for a one-liner) to the left.

Ragged right (default) opt-in · typeset--ragged — names the default; no class needed to get it

alignleft
align last lineleft
hyphenationmanual — never automatic
line breakinghigh effort: avoid a very short last line, and even out the right edge
applies toparagraph, list item, blockquote, definition description, callout

Hyphenation exists to serve justification. Without justification a hyphen breaks a word for no gain, so it stays off.

The rag itself is the thing to judge: an even rag reads as deliberate, a rag with one very short line and one very long one reads as an accident.

Justified

alignjustify
align last lineleft
justify methodinter-word
hyphenationautomatic
hyphenation min word chars6
hyphenation min chars before break3
hyphenation min chars after break3
max consecutive hyphens2
applies toparagraph, list item, blockquote, definition description, callout

Never justified

alignleft
hyphenationmanual
applies toall headings, subtitle, byline, dateline, abstract, attribution, epigraph, verse, caption, figure caption, address block, table cell, table header, code block, table of contents, bibliography, endnotes, sidenote, letterhead, salutation, closing, signature, enclosures, postscript, definition term
/* Justification without hyphenation opens rivers of white space.
         Both, or neither. And `lang` must be set — hyphenation is per-language. */
      .typeset--justified {
        text-align: justify;
        text-align-last: left;      /* the last line is never stretched */
        text-justify: inter-word;
        hyphens: auto;
        hyphenate-limit-chars: 6 3 3;     /* min word, before break, after break */
        -webkit-hyphenate-limit-before: 3;
        -webkit-hyphenate-limit-after: 3;
        hyphenate-limit-lines: 2;         /* never stack 3 hyphens in a row */
      }

      /* Ragged right. This is the default — the point of naming it is so a document
         can state the choice rather than express it as the absence of a class, and so
         the two options read as siblings in the source.
      
         `text-wrap: pretty` asks the engine to spend more effort on the rag: it
         avoids a very short last line and evens out the right edge, which is the one
         thing a ragged setting can get wrong. Hyphenation stays off — a hyphen exists
         to help justification, and without justification it only breaks a word for
         no gain. */
      .typeset--ragged {
        text-align: left;
        text-align-last: left;
        hyphens: manual;
        text-wrap: pretty;
      }

      /* `text-align-last` inherits as well, so a block that centres itself must
         restore it — otherwise its last line, which for a one-line block is its only
         line, is flushed left. See .ts-pullquote and .ts-break.
      
         Never justify a heading, a cell, or a listing. Every other block that must
         stay flush left — subtitle, byline, caption, address, notes — declares
         `text-align: left` in its own rule, because a direct match always beats an
         inherited value and its descendants then inherit `left` in turn. Relying on
         an exception list here instead means every new block silently opts in. */
      .typeset--justified h1,
      .typeset--justified h2,
      .typeset--justified h3,
      .typeset--justified h4,
      .typeset--justified td,
      .typeset--justified pre { text-align: left; hyphens: manual; }

Set globally rather than per element — this is the “foundation” region of typeset.typ.

set text(
          font: serif,
          size: scale.base,
          fill: ink,
          top-edge: 1em,
          bottom-edge: 0pt,
          ..oldstyle,
        )

        set par(
          leading: leading-for(scale.leading),
          spacing: if indented { leading-for(scale.leading) } else { sp },
          justify: justified,
          first-line-indent: if indented { (amount: 1.5em, all: false) } else { 0pt },

09 — Prose

Drop cap

For the opening of an essay or a chapter, and nowhere else. The font-size and line-height are tuned together so the cap covers exactly three lines at the default leading — change the leading and this needs retuning.

The opening words go in small caps: without them the jump from 33pt to 11pt is too abrupt, and the eye skips the first line.

How it must look

There is nothing especially modern about wanting a document to look considered. The drop cap descends from the illuminated initial, which existed for a practical reason: in a manuscript with no title page and no table of contents, a large decorated letter was the only way to tell the reader that something had begun.

The practical reason is gone. The signal is not.

  • For the opening of an essay or a chapter, and nowhere else.
  • The size and line height are tuned together to cover exactly three lines. Change the leading and the cap must be retuned.
  • The opening words go in small caps. Without them the jump from 33pt to 11pt is too abrupt and the eye skips the first line.

Drop cap

fontserif
weight400
size3.05em
size pt33.55pt
line height0.86
coloraccent
floatleft
lines covered3
padding right0.06em
padding top0.02em
first line indent0

Opening words

variantall small caps
tracking0.04em
extentthe first two to four words
.typeset .ts-dropcap::first-letter {
        float: left;
        font-family: var(--ts-serif);
        font-size: 3.05em;         /* tuned to cover exactly three lines */
        line-height: 0.86;
        font-weight: 400;
        color: var(--ts-accent);
        padding: 0.02em 0.06em 0 0;
      }
      .typeset .ts-dropcap { text-indent: 0; }

      /* A drop cap wants the opening words in small caps to bridge the size jump. */
      .typeset .ts-lede { font-variant-caps: all-small-caps; letter-spacing: 0.04em; }
#let dropcap(body) = {
        let letters = body.text
        let initial = letters.first()
        let rest = letters.slice(1)
        block(above: 0pt, {
          place(
            left,
            dx: -1.35em,
            dy: -0.08em,
            text(size: 3.05em, fill: accent, top-edge: "cap-height", bottom-edge: "baseline")[#initial],
          )
          par(first-line-indent: 0pt)[#rest]
        })
      }

10 — Prose

Inline emphasis

Nested emphasis flips to roman rather than compounding — correct typographic behaviour, and something most engines get wrong by default. A superscript or subscript must not alter the leading of the line it sits on; a footnote reference that opens up its line is a defect.

Underline is deliberately reserved for <ins>. On paper it is a typewriter's substitute for italic, and there is no reason to imitate a typewriter.

How it must look

Ordinary text, with bold for the load-bearing clause and italic for a title or a term of art. Emphasis nested inside emphasis flips back to roman. Bold and italic together when both apply.

Named entities take small caps: the Bauhaus closed in 1933, and PDF arrived sixty years later. Water is H2O; the claim is contested.3

Revisions read as struck through and inserted, a phrase can be marked, and a key is ⌘P.

  • Nested emphasis FLIPS to roman rather than compounding. This is correct typographic behaviour and most engines get it wrong by default.
  • Superscripts and subscripts must not disturb the line's leading.
  • Underline is reserved for insertions. On paper it is a typewriter's substitute for italic, and there is no reason to imitate a typewriter.

Bold

weight600

For the load-bearing clause.

Italic

styleitalic

For a title or a term of art.

Nested italic

stylenormal

Small caps

variantall small caps
tracking0.05em
numeralsoldstyle

Named entities, acronyms, stage directions.

Abbreviation

variantall small caps
tracking0.05em
underline0.5pt dotted
underline colorink_faint

Superscript

size0.72em
baseline shift+0.45em
line height0
affects leadingno

Subscript

size0.72em
baseline shift-0.22em
line height0
affects leadingno

Deletion

decorationline-through
colorink_muted

Insertion

decorationunderline
underline offset0.15em

Highlight

backgroundwash
bleed2pt beyond the glyphs

Key

fontsans
size0.85em
padding0.15em 0.35em
border0.5pt rule
border bottom1.5pt rule
radius2pt
.typeset strong, .typeset b { font-weight: 600; }
      .typeset em, .typeset i { font-style: italic; }
      .typeset em em, .typeset em i { font-style: normal; }   /* nested emphasis flips */
      .typeset strong em, .typeset em strong { font-weight: 600; font-style: italic; }

      /* Small caps for named entities, acronyms, and stage directions. */
      .typeset .ts-sc {
        font-variant-caps: all-small-caps;
        letter-spacing: 0.05em;
        font-variant-numeric: oldstyle-nums;
      }

      .typeset abbr[title] {
        font-variant-caps: all-small-caps;
        letter-spacing: 0.05em;
        text-decoration: none;
        border-bottom: 0.5pt dotted var(--ts-ink-faint);
      }

      .typeset s, .typeset del { text-decoration: line-through; color: var(--ts-ink-muted); }
      .typeset ins { text-decoration: underline; text-underline-offset: 0.15em; }
      .typeset mark { background: var(--ts-wash); box-shadow: 0 0 0 2pt var(--ts-wash); }

      /* Real super/subscripts, not shifted full-size digits. */
      .typeset sup, .typeset sub {
        font-size: 0.72em;
        line-height: 0;
        position: relative;
        vertical-align: baseline;
      }
      .typeset sup { top: -0.45em; }
      .typeset sub { bottom: -0.22em; }

      .typeset kbd {
        font: 0.85em/1 var(--ts-sans);
        padding: 0.15em 0.35em;
        border: 0.5pt solid var(--ts-rule);
        border-bottom-width: 1.5pt;
        border-radius: 2pt;
      }
show strong: set text(weight: 600)
        show link: it => underline(offset: 0.14em, stroke: 0.5pt, it)
        show footnote: set text(fill: accent, size: 0.7em, ..lining)
        set footnote.entry(separator: line(length: 30%, stroke: 0.5pt + rule-color))
        show footnote.entry: set text(size: sm)

12 — Prose

Numerals

Three kinds, three jobs. Old-style figures have ascenders and descenders, sit inside the x-height, and belong in running prose where uniform-height digits shout. Lining figures are all cap-height, for headings. Tabular figures share one advance width, which is the only reason a column of numbers can align.

Getting this wrong is the most visible amateur tell in a set document — and in every engine it is one setting.

How it must look

Old-style, in prose — between 1908 and 1935 the foundry cut 47 sizes, of which 12 survive.

Lining, for display — BETWEEN 1908 AND 1935, 47 SIZES

CutSizesSurviving
Original4712
Revival88
Digital1,2041,204

Fractions: 1/2, 3/4, 7/8

  • Three kinds, three jobs. Getting this wrong is the most visible amateur tell in a set document, and it is one setting.
  • Old-style figures have ascenders and descenders and sit inside the x-height. Lining figures are uniform cap height. Tabular figures share one advance width, which is the only reason a column of numbers can align.

In running prose

numeralsoldstyle proportional

In headings and display

numeralslining proportional

In tables and columns

numeralslining tabular

Fractions

formdiagonal
/* Old-style figures sit in the x-height and belong in running prose.
         Lining figures are uniform-height and belong in headings and tables.
         Tabular figures share one advance width so columns align. */
      .typeset .ts-nums-oldstyle { font-variant-numeric: oldstyle-nums proportional-nums; }
      .typeset .ts-nums-lining   { font-variant-numeric: lining-nums proportional-nums; }
      .typeset .ts-nums-tabular  { font-variant-numeric: lining-nums tabular-nums; }
      .typeset .ts-frac          { font-variant-numeric: diagonal-fractions; }

Set globally rather than per element — this is the “foundation” region of typeset.typ.

set text(
          font: serif,
          size: scale.base,
          fill: ink,
          top-edge: 1em,
          bottom-edge: 0pt,
          ..oldstyle,
        )

        set par(
          leading: leading-for(scale.leading),
          spacing: if indented { leading-for(scale.leading) } else { sp },
          justify: justified,
          first-line-indent: if indented { (amount: 1.5em, all: false) } else { 0pt },

13 — Blocks

Quotations

Four different jobs, four treatments. They are shown one at a time below, because the whole point is that a reader should be able to tell them apart at a glance — if you cannot say which is which, the styling has failed.

Attribution always sits outside the quotation, prefixed with an em dash. Putting it inside makes the source part of what was said.

1 · Block quote

Evidence: someone else's words, quoted to be examined. A hairline rule down the left, indented, one point smaller than the body. The rule is the signal — it marks the full vertical extent of what is quoted, so the reader knows exactly where the quotation stops.

Ruder puts the case for restraint more sharply than anyone since:

Typography has one plain duty before it and that is to convey information in writing. No argument or consideration can absolve typography from this duty.

Emil Ruder, Typographie, 1967

Which is a hard standard, and the right one.

2 · Epigraph

A quotation that opens a chapter, before the argument starts. No rule and no indent — it is not evidence, so it gets no apparatus. Instead it is set italic, smaller, in muted ink, and pushed to the right margin. Nothing follows it except the text it introduces.

A book is a machine to think with.

I. A. Richards

There is nothing especially modern about wanting a document to look considered…

3 · Pull quote

Display type lifted out of the body — the reader has already met these words, or will. Rules above and below, centred, sans-serif at 15pt. Because it repeats, it must never be the only place a claim appears: a skimming reader would get the claim without its qualification.

…the sentence applies with more force to layout than to decoration. An over-wide column is not an aesthetic misjudgement; it is a failure to convey.

No argument can absolve typography from this duty.

And the duty is discharged in the measure long before it is discharged in the typeface.

4 · Verse

Quoted poetry, where the line breaks are the author's and must survive. Indented as a block, with runover lines indented further than the verse line they continue — so a line that wrapped can never be mistaken for a line the poet wrote.

Whose woods these are I think I know. His house is in the village though; He will not see me stopping here To watch his woods fill up with snow.Robert Frost
  • Four jobs, four treatments. A block quote is evidence. An epigraph opens a chapter. A pull quote is display type lifted from the body. Verse preserves the poet's line breaks.
  • A pull quote repeats text that already appears in the body, so it MUST NOT be the only place a claim appears — a skimming reader would get the claim without its qualification.
  • Attribution always sits outside the quotation. Inside, it becomes part of what was said.

Block quote

fontserif
size10.5pt
line height1.45
colorink
space before13.75pt
space after13.75pt
indent left16.5pt
border left1pt rule
break insideavoid
last child space after0

Attribution

displayblock
alignleft
space before5.5pt
size9.5pt
stylenormal
colorink_muted
prefixem dash and a space

Epigraph

fontserif
styleitalic
size10pt
colorink_muted
alignleft
block alignmentflush right
max width24em
bordernone
space after22pt

Cite inside an epigraph is roman, not italic — the surrounding block is already italic.

Pull quote

fontsans
weight300
size15pt
line height1.3
aligncentre
wrapbalanced
border top1.5pt rule_strong
border bottom0.5pt rule
padding top11pt
padding bottom11pt
space before16.5pt
space after16.5pt

Verse

fontserif
size11pt
alignleft
line breakspreserved as authored
indent left22pt
runover indent1.5em
bordernone

Runover lines indent further than the verse line they continue, so a wrapped line cannot be mistaken for a new one.

.typeset blockquote {
        margin: calc(var(--ts-space) * 1.25) 0;
        padding-left: calc(var(--ts-space) * 1.5);
        border-left: 1pt solid var(--ts-rule);
        color: var(--ts-ink);
        font-size: 10.5pt;
        break-inside: avoid;
      }
      .typeset blockquote > :last-child { margin-bottom: 0; }

      /* Attribution: an em dash, and never inside the quote. */
      .typeset blockquote cite,
      .typeset .ts-attribution {
        display: block;
        text-align: left;
        margin-top: calc(var(--ts-space) * 0.5);
        font-size: var(--ts-sm);
        font-style: normal;
        color: var(--ts-ink-muted);
      }
      .typeset blockquote cite::before,
      .typeset .ts-attribution::before { content: "— "; }

      /* An epigraph opens a chapter: no rule, indented from the right, italic. */
      .typeset .ts-epigraph {
        border: 0;
        text-align: left;
        margin: 0 0 calc(var(--ts-space) * 2) auto;
        padding: 0;
        max-width: 24em;
        font-style: italic;
        font-size: 10pt;
        color: var(--ts-ink-muted);
      }
      .typeset .ts-epigraph cite { font-style: normal; }

      /* A pull quote is display type lifted from the body. It repeats — so it must
         never be the only place a claim appears. */
      .typeset .ts-pullquote {
        border: 0;
        text-align: center;
        text-align-last: center;
        border-top: 1.5pt solid var(--ts-rule-strong);
        border-bottom: 0.5pt solid var(--ts-rule);
        padding: var(--ts-space) 0;
        margin: calc(var(--ts-space) * 1.5) 0;
        font-family: var(--ts-sans);
        font-size: 15pt;
        font-weight: 300;
        line-height: 1.3;
        text-wrap: balance;
      }

      /* Verse: preserve the poet's line breaks, indent the runovers. */
      .typeset .ts-verse {
        border: 0;
        text-align: left;
        padding-left: calc(var(--ts-space) * 2);
        white-space: pre-line;
        font-size: var(--ts-base);
        text-indent: -1.5em;
        padding-inline-start: calc(var(--ts-space) * 2 + 1.5em);
      }
show quote.where(block: true): it => block(
          above: sp * 1.25,
          below: sp * 1.25,
          inset: (left: sp * 1.5),
          stroke: (left: 1pt + rule-color),
          breakable: false,
          {
            set text(size: 0.955em)
            it.body
            if it.attribution != none {
              set text(size: sm, fill: ink-muted, style: "normal")
              set par(first-line-indent: 0pt, justify: false)
              block(above: sp * 0.5, [— #it.attribution])
            }
          },
        )

14 — Blocks

Lists

The marker's colour, weight and column width must be under the spec's control, not the engine's defaults. A grey marker with black text reads as one thing; a black marker competes with the first word. Runover lines align to the text, never to the marker.

Definition lists are underused. They are the right shape for a glossary, a set of terms, or the field-and-value blocks in a letter — anywhere a short label governs a paragraph.

How it must look

  • An unordered item, with a grey mid-dot for a marker.
  • A second item, long enough to wrap onto a second line so you can see that the runover aligns to the text, not to the marker.
  • A nested list follows:
    • The nested marker is an en dash.
    • Two levels is the limit worth designing for.
  1. Ordered items use tabular lining figures, so 9 and 10 align.
  2. Sub-items switch to lower alpha:
    1. First sub-item.
    2. Second sub-item.
  3. The tenth item would still align. It is the small things.
Measure
The length of a line of type, counted in characters rather than in millimetres.
Leading
The vertical distance between baselines. Named for the strips of lead once inserted between lines of metal type.
Widow
A last line of a paragraph carried alone to the top of a page. An orphan is the mirror case at the foot.
  • The marker's colour, weight and width must be under control. A grey marker with black text reads as one thing; a black marker competes with the first word.
  • Runover lines align to the text, never to the marker.
  • Two levels of nesting is the limit worth designing for.
  • Definition lists are underused: they are the right shape for a glossary, a set of terms, or the field-and-value blocks of a letter.

Unordered list

marker·
marker colorink_muted
marker weight700
marker column width1.4em
indent1.4em
item space after2.75pt
space after11pt

Unordered list, nested

marker
marker weight400
space before2.75pt

Ordered list

marker1.
marker colorink_muted
numeralslining tabular
marker column width1.4em
indent1.4em
item space after2.75pt

Tabular numerals so 9 and 10 align.

Ordered list, nested

markera.

Tight list opt-in · ts-list-tight

item space after0

For enumerations that are not prose.

Definition term

fontsans
weight600
size9.5pt
alignleft
space before6.6pt
space before first0
break afteravoid

Definition description

fontserif
size11pt
indent left16.5pt
space after0
.typeset ul,
      .typeset ol {
        margin: 0 0 var(--ts-space);
        padding-left: 1.4em;
      }
      .typeset li { margin-bottom: calc(var(--ts-space) * 0.25); }
      .typeset li > ul,
      .typeset li > ol { margin: calc(var(--ts-space) * 0.25) 0 0; }
      .typeset li:last-child { margin-bottom: 0; }

      .typeset ul { list-style: none; }
      .typeset ul > li::before {
        content: "·";
        float: left;
        width: 1.4em;
        margin-left: -1.4em;
        text-align: left;
        color: var(--ts-ink-muted);
        font-weight: 700;
      }
      .typeset ul ul > li::before { content: "–"; font-weight: 400; }

      .typeset ol {
        list-style: none;
        counter-reset: ts-ol;
      }
      .typeset ol > li { counter-increment: ts-ol; }
      .typeset ol > li::before {
        content: counter(ts-ol) ".";
        float: left;
        width: 1.4em;
        margin-left: -1.4em;
        color: var(--ts-ink-muted);
        font-variant-numeric: lining-nums tabular-nums;
      }
      .typeset ol ol > li::before { content: counter(ts-ol, lower-alpha) "."; }

      /* A tight list for enumerations that are not prose. */
      .typeset .ts-list-tight li { margin-bottom: 0; }

      /* Definition lists: the right shape for terms, glossaries, and letter fields. */
      .typeset dl { margin: 0 0 var(--ts-space); }
      .typeset dt {
        text-align: left;
        font-weight: 600;
        font-family: var(--ts-sans);
        font-size: var(--ts-sm);
        margin-top: calc(var(--ts-space) * 0.6);
        break-after: avoid;
      }
      .typeset dt:first-child { margin-top: 0; }
      .typeset dd { margin: 0 0 0 calc(var(--ts-space) * 1.5); }
set list(marker: ([#text(fill: ink-muted, weight: 700)[·]], [#text(fill: ink-muted)[–]]), indent: 0pt, body-indent: 1.4em, spacing: sp * 0.25)
        set enum(numbering: "1.", indent: 0pt, body-indent: 1.4em, spacing: sp * 0.25, number-align: left)
        show enum: set text(..tabular)

        set terms(separator: linebreak(), indent: 0pt, hanging-indent: sp * 1.5, spacing: sp * 0.6)
        show terms: set par(first-line-indent: 0pt)
        show terms.item: it => block(above: sp * 0.6, below: 0pt, {
          block(below: 0pt, text(font: sans, size: sm, weight: 600, it.term))
          block(inset: (left: sp * 1.5), it.description)
        })

15 — Blocks

Tables

Rules, not grids. Vertical rules are almost never needed — the columns already read as columns, and every line you add is ink competing with data. Three horizontal rules: above the header, below it, and below the body.

Numbers align right, on tabular figures, and units go in the header rather than being repeated in every cell. Sans-serif at 9.5pt: a table is scanned, not read, and the serif's job is reading.

A table that spans pages repeats its header on every one. Without that, page two of a table is unreadable — this is a requirement, not a nicety.

How it must look

Table 1 — Setting widths for a single-column A4 page, by point size.
Typeface Size (pt) Leading Measure (mm)
EB Garamond11.01.45126
Source Serif10.51.42124
Charis SIL10.01.40119
IBM Plex Serif10.51.45131
Median10.51.43125
  • Rules, not grids. Vertical rules are almost never needed — the columns already read as columns, and every added line is ink competing with data.
  • Three horizontal rules only: above the header, below the header, below the body.
  • A table is scanned, not read. Sans at 9.5pt; the serif's job is reading.
  • Numbers align on their right edge, on tabular figures. Units go in the header, not in every cell.
  • A table that spans pages MUST repeat its header on each. Without it, page two of a table is unreadable.

Table

fontsans
size9.5pt
line height1.35
numeralslining tabular
widthfull measure
space before13.75pt
space after13.75pt
border collapseyes

Header cell

fontsans
weight600
size8pt
caseuppercase
tracking0.07em
colorink_muted
alignleft
border bottom1pt rule_strong
padding0.45em 0.7em 0.45em 0
repeats across pagesyes

Body cell

alignleft
vertical alignbaseline
border bottom0.5pt rule
padding0.45em 0.7em 0.45em 0
last row border bottom1pt rule_strong

Numeric cell

alignright
numeralslining tabular

Footer cell

weight600
border bottomnone

Caption

positionabove the table
alignleft
fontsans
size9.5pt
colorink
space after0.5em
labelsmall caps, weight 600, tracking 0.06em
max widthnone

Row

break insideavoid

Zebra striping opt-in · ts-table-zebra

backgroundwash on even body rows

For wide, dense tables only. Not a default.

.typeset table {
        width: 100%;
        border-collapse: collapse;
        margin: calc(var(--ts-space) * 1.25) 0;
        font-family: var(--ts-sans);
        font-size: var(--ts-sm);
        font-variant-numeric: lining-nums tabular-nums;
        line-height: 1.35;
      }

      /* Rules, not grids. Vertical rules are almost never needed — the columns
         already read as columns. */
      .typeset th,
      .typeset td {
        padding: 0.45em 0.7em 0.45em 0;
        text-align: left;
        vertical-align: baseline;
        border-bottom: 0.5pt solid var(--ts-rule);
      }
      .typeset th:last-child,
      .typeset td:last-child { padding-right: 0; }

      .typeset thead th {
        font-weight: 600;
        font-size: var(--ts-xs);
        letter-spacing: 0.07em;
        text-transform: uppercase;
        color: var(--ts-ink-muted);
        border-bottom: 1pt solid var(--ts-rule-strong);
      }
      .typeset tbody tr:last-child td { border-bottom: 1pt solid var(--ts-rule-strong); }
      .typeset tfoot td { font-weight: 600; border-bottom: 0; }

      /* Numbers align on their right edge. Units go in the header, not every cell. */
      .typeset .ts-num { text-align: right; font-variant-numeric: lining-nums tabular-nums; }
      .typeset th.ts-num { text-align: right; }

      /* Repeat the header on every page a long table spans. */
      .typeset thead { display: table-header-group; }
      .typeset tfoot { display: table-footer-group; }
      .typeset tr { break-inside: avoid; }

      .typeset caption {
        caption-side: top;
        text-align: left;
        font-family: var(--ts-sans);
        font-size: var(--ts-sm);
        color: var(--ts-ink);
        padding-bottom: 0.5em;
        max-width: none;
      }
      .typeset caption .ts-label {
        font-weight: 600;
        font-variant-caps: all-small-caps;
        letter-spacing: 0.06em;
      }

      /* Optional zebra for wide, dense tables only. */
      .typeset .ts-table-zebra tbody tr:nth-child(even) { background: var(--ts-wash); }
set table(
          stroke: (x, y) => (bottom: if y == 0 { 1pt + rule-strong } else { 0.5pt + rule-color }),
          inset: (left: 0pt, right: 0.7em, top: 0.45em, bottom: 0.45em),
          align: left + horizon,
        )
        show table: set text(font: sans, size: sm, ..tabular)
        show table: set par(justify: false, leading: leading-for(1.35), first-line-indent: 0pt)
        show table.cell.where(y: 0): set text(size: xs, weight: 600, fill: ink-muted, tracking: 0.07em)
        show table.cell.where(y: 0): upper

16 — Blocks

Code

Monospaced faces run optically large beside a serif, so inline code is pulled back to 0.86em and ligatures are switched off — an arrow rendered as a single glyph is charming in an editor and wrong in a document that quotes source.

Paper cannot scroll, so blocks wrap rather than overflow. And long listings must be allowed to split across pages: forbidding the break on a fifty-line block pushes a blank page ahead of it.

How it must look

The declaration is a single line — set font-variant-numeric on the table and every cell inherits it:

.typeset table {
        font-variant-numeric: lining-nums tabular-nums;
      }

      /* Repeat the header on every page the table spans. */
      .typeset thead { display: table-header-group; }

Which is all it takes.

Inline code

Inline code

fontmono
size0.86em
ligaturesdisabled
backgroundwash
padding0.1em 0.28em
radius2pt
wrapmay break within a word

The 0.86em corrects for the monospace face running optically large beside the serif.

Ligatures off: an arrow rendered as one glyph is charming in an editor and wrong in a document that quotes source.

Code blocks

  • Paper cannot scroll. Lines wrap; they are never clipped.
  • A long listing MUST be allowed to split across pages. Forbidding the break pushes a blank page ahead of it.

Code block

fontmono
size8.5pt
line height1.45
backgroundwash
border left2pt rule_strong
padding0.8em 1em
space before13.75pt
space after13.75pt
wrapsoft-wrap long lines
tab size2
break insideavoid on screen, allowed in print
.typeset code {
        font-family: var(--ts-mono);
        font-size: 0.86em;          /* mono runs large; pull it back optically */
        font-variant-ligatures: none;
        background: var(--ts-wash);
        padding: 0.1em 0.28em;
        border-radius: 2pt;
        overflow-wrap: break-word;
      }

      .typeset pre {
        font-family: var(--ts-mono);
        font-size: 8.5pt;
        line-height: 1.45;
        background: var(--ts-wash);
        border-left: 2pt solid var(--ts-rule-strong);
        padding: 0.8em 1em;
        margin: calc(var(--ts-space) * 1.25) 0;
        overflow-x: auto;

        /* Paper cannot scroll. Wrap, and mark the wrap. */
        white-space: pre-wrap;
        overflow-wrap: break-word;
        tab-size: 2;
        break-inside: avoid;
      }
      .typeset pre code {
        background: none;
        padding: 0;
        font-size: inherit;
      }

      @media print {
        .typeset pre { break-inside: auto; }   /* long listings must be allowed to split */
      }
show raw: set text(font: mono, size: 0.86em, features: (liga: 0))
        show raw.where(block: false): box.with(fill: wash, inset: (x: 0.28em), outset: (y: 0.1em), radius: 2pt)
        show raw.where(block: true): it => block(
          width: 100%,
          fill: wash,
          stroke: (left: 2pt + rule-strong),
          inset: (x: 1em, y: 0.8em),
          above: sp * 1.25,
          below: sp * 1.25,
          breakable: true,
          { set text(size: 0.773em); set par(leading: leading-for(1.45), justify: false); it },
        )

17 — Blocks

Figures & captions

A caption is not a title. It goes below the figure, sans at 9.5pt, separated by a hair rule, and it carries a small-caps label so a cross-reference in the text has something to point at. A figure and its caption stay on one page — the one place a no-break rule is unambiguously right.

How it must look

characters per line 55–75 30 120
Figure 1 — Reading comfort against line length. The plateau between 55 and 75 characters is why the measure, not the font size, is the first decision.
  • A caption is not a title: it goes below the figure.
  • A figure and its caption stay on one page. This is the one place a no-break rule is unambiguously right.
  • The caption carries a small-caps label so a cross-reference in the text has something to point at.

Figure

space before16.5pt
space after16.5pt
break insideavoid
image max widthfull measure

Figure caption

positionbelow the figure
fontsans
size9.5pt
line height1.4
colorink_muted
alignleft
space before0.5em
border top0.5pt rule
padding top0.4em
labelsmall caps, weight 600, colour ink, tracking 0.06em
.typeset figure {
        margin: calc(var(--ts-space) * 1.5) 0;
        break-inside: avoid;
      }
      .typeset figure img,
      .typeset figure svg {
        display: block;
        max-width: 100%;
        height: auto;
      }
      .typeset figcaption {
        text-align: left;
        font-family: var(--ts-sans);
        font-size: var(--ts-sm);
        line-height: 1.4;
        color: var(--ts-ink-muted);
        margin-top: 0.5em;
        padding-top: 0.4em;
        border-top: 0.5pt solid var(--ts-rule);
      }
      .typeset figcaption .ts-label {
        font-weight: 600;
        color: var(--ts-ink);
        font-variant-caps: all-small-caps;
        letter-spacing: 0.06em;
      }
show figure: set block(above: sp * 1.5, below: sp * 1.5, breakable: false)
        show figure.caption: it => block(
          width: 100%,
          inset: (top: 0.4em),
          stroke: (top: 0.5pt + rule-color),
          {
            set text(font: sans, size: sm, fill: ink-muted)
            set par(justify: false, leading: leading-for(1.4), first-line-indent: 0pt)
            align(left, [#text(fill: ink, weight: 600, tracking: 0.06em, ..smcp)[#it.supplement #context it.counter.display()] — #it.body])
          },
        )

18 — Blocks

Callouts

For a report or a technical note: an aside the reader may skip without losing the argument. If skipping it would lose the argument, it is not a callout, it is a paragraph. Two variants only — neutral and warning — because a document with five callout colours has none.

How it must look

Note

Point sizes assume the document is printed at 100%. “Fit to page” scaling in a print dialog silently invalidates every measurement in this stylesheet.

Before you send it

Check that fonts are embedded in the PDF. A document that falls back to Times on the recipient's machine has lost every decision above.

  • A callout is an aside the reader may skip without losing the argument. If skipping it would lose the argument, it is a paragraph.
  • Two variants only. A document with five callout colours has none.

Callout

fontserif
size10pt
backgroundwash
border0.5pt rule
border left2.5pt rule_strong
padding0.75em 1em
space before13.75pt
space after13.75pt
break insideavoid

Callout title

fontsans
weight700
size8pt
caseuppercase
tracking0.09em
colorink_muted
space after0.35em

Callout — warning

border left2.5pt accent
title coloraccent
.typeset .ts-callout {
        margin: calc(var(--ts-space) * 1.25) 0;
        padding: 0.75em 1em;
        border: 0.5pt solid var(--ts-rule);
        border-left: 2.5pt solid var(--ts-rule-strong);
        background: var(--ts-wash);
        font-size: 10pt;
        break-inside: avoid;
      }
      .typeset .ts-callout > :last-child { margin-bottom: 0; }
      .typeset .ts-callout-title {
        font-family: var(--ts-sans);
        font-size: var(--ts-xs);
        font-weight: 700;
        letter-spacing: 0.09em;
        text-transform: uppercase;
        color: var(--ts-ink-muted);
        margin-bottom: 0.35em;
      }
      .typeset .ts-callout--warning { border-left-color: var(--ts-accent); }
      .typeset .ts-callout--warning .ts-callout-title { color: var(--ts-accent); }
#let callout(title: none, warning: false, body) = context {
        let u = text.size
        block(
        above: u * 1.25, below: u * 1.25, width: 100%,
        fill: wash,
        stroke: (
          rest: 0.5pt + rule-color,
          left: 2.5pt + (if warning { accent } else { rule-strong }),
        ),
        inset: (x: 1em, y: 0.75em),
        breakable: false,
        {
          set text(size: u * 0.91)
          if title != none {
            block(below: 0.35em, text(
              font: sans, size: u * 0.73, weight: 700, tracking: 0.09em,
              fill: if warning { accent } else { ink-muted },
              upper(title),
            ))
          }
          body
        },
        )
      }

19 — Blocks

Section breaks

A blank line cannot survive a page break. If the break happens to fall exactly on the gap, the reader never learns that the scene changed — and this is not a rare accident, it is a one-in-forty-lines certainty over a long document. Use a visible mark.

Four registers: three asterisks for prose, an asterism for a heavier division, a fleuron for a decorative one, a plain rule for a report.

How it must look

…and so the first argument closes, having established rather less than it set out to.


The second begins somewhere else entirely.

An asterism marks a heavier division — a change of part, not of scene.

A fleuron, where the document can afford ornament.

And a plain rule, where it cannot.

  • A blank line cannot survive a page break: if the break falls on the gap, the reader never learns the scene changed. Over a long document this is a certainty, not an accident. The mark MUST be visible.
  • Four registers, one per kind of division.

Break — three asterisks

content* * *
size10pt
tracking0.6em
colorink_faint
aligncentre
space before16.5pt
space after16.5pt
break afteravoid

The default, for a change of scene in prose.

Break — asterism

content
size14pt
colorink_faint
aligncentre

A heavier division: a change of part.

Break — fleuron

content
size12pt
coloraccent
aligncentre

Where the document can afford ornament.

Break — rule

border top0.5pt rule
contentnone

Where it cannot. Reports.

/* A blank line cannot survive a page break — if the break falls on the gap,
         the reader never sees it. Use a visible mark. */
      .typeset hr,
      .typeset .ts-break {
        border: 0;
        height: auto;
        margin: calc(var(--ts-space) * 1.5) 0;
        text-align: center;
        text-align-last: center;   /* a one-line block IS its own last line */
        color: var(--ts-ink-faint);
        break-after: avoid;
      }
      .typeset hr::before,
      .typeset .ts-break::before {
        content: "* * *";
        letter-spacing: 0.6em;
        font-size: 10pt;
      }

      /* An asterism, for a heavier division. */
      .typeset .ts-break--asterism::before { content: "⁂"; letter-spacing: 0; font-size: 14pt; }
      /* A fleuron, for a decorative one. */
      .typeset .ts-break--fleuron::before { content: "❦"; letter-spacing: 0; font-size: 12pt; color: var(--ts-accent); }
      /* A plain rule, for a report. */
      .typeset .ts-break--rule::before { content: none; }
      .typeset .ts-break--rule { border-top: 0.5pt solid var(--ts-rule); height: 0; }
#let break-scene(kind: "asterisks") = block(above: sp * 1.5, below: sp * 1.5, sticky: true, width: 100%, align(center, {
        if kind == "asterisks" { box(text(size: 10pt, fill: ink-faint, tracking: 0.6em)[\* \* \*]) }
        else if kind == "asterism" { box(text(size: 14pt, fill: ink-faint)[⁂]) }
        else if kind == "fleuron" { box(text(size: 12pt, fill: accent)[❦]) }
        else if kind == "rule" { line(length: 100%, stroke: 0.5pt + rule-color) }
      }))

20 — Apparatus

Footnotes & sidenotes

The honest position: true bottom-of-page footnotes need an engine that can measure the page while laying it out. Typst and LaTeX do this natively. WeasyPrint 53+ and Prince implement the CSS footnote model. No browser does. So the note text lives in the document inside .ts-note, and @supports (float: footnote) promotes it to a real footnote where the engine can — degrading to a numbered notes block where it cannot, never to a lost note.

There is no heading over the notes. The rule above the block is the only mark it needs; a label over two lines of small print is heavier than the thing it labels.

Sidenotes are the better default where the page can afford the margin: no page-break arithmetic, they work in every renderer, and the reader never leaves the sentence they are in. They need the full page width to make sense, so the second example below is a whole A4 sheet at 42% rather than a fragment.

Notes at the end — the fallback

A rule, then the numbered notes. On a one-page document this is the page foot, so it reads as a footnote area without needing to be one.

Bringhurst puts the ideal measure between 45 and 75 characters, a range narrow enough to be useful and wide enough to survive a change of typeface. The lower bound matters more in a two-column layout.

  1. Robert Bringhurst, The Elements of Typographic Style, 4th ed., §2.1.2.
  2. Below about 40 characters, hyphenation stops being optional.
  • Bottom-of-page footnotes require an engine that can measure the page while laying it out. Typst and LaTeX do this natively. WeasyPrint 53+ and Prince implement the CSS footnote model. No browser does.
  • Where footnotes are unavailable the note text MUST still live in the document, degrading to a numbered endnotes block — never to a lost note.
  • Sidenotes are the better default where the page can afford the margin: no page-break arithmetic, and the reader never leaves the sentence they are in.
  • The reference mark must not disturb the leading of the line it sits on.

Reference mark

contentthe note number
size0.7em
baseline shift+0.45em
line height0
coloraccent
numeralslining
decorationnone
bracketsnone
affects leadingno

Footnote

positionfoot of the page carrying the mark
fontserif
size9.5pt
line height1.35
separatora rule above the notes area

Where unsupported — an endnotes block at the end of the document

Endnotes block

fontserif
size9.5pt
line height1.4
alignleft
space before22pt
border top0.5pt rule
padding top11pt
list indent1.8em
item space after0.4em
headingnone — the rule above the block is the only mark it needs

No 'NOTES' label. In a one-page document the rule plus the numbered list already reads as a notes area, and a heading over two lines of small print is heavier than the thing it labels.

Sidenote opt-in · typeset--sidenotes

fontsans
size8pt
line height1.4
colorink_muted
alignleft
width11em
positionthe right margin, 2em past the text column's own right edge
vertical offset-0.3em from the marker's line
marker prefixthe note number in accent, weight 600
measure when active27em
reserved margin14em — the gap plus the note, with an em to spare

Anchor the note to the text column's right edge, not to the measure. The measure is a maximum: where the reserved margin is the binding constraint the column is narrower than it, and a note offset from the measure lands on top of the text.

Sidenotes are positioned, not flowed: a long note followed closely by another will overlap it. Keep each note short and consecutive notes apart — one per paragraph, and not in every paragraph. An implementation cannot fix this, and should not pretend to; it is a constraint on the writing.

/* The reference mark. Superscript, no brackets, and it must not disturb
         the line's leading. */
      .typeset .ts-noteref {
        font-size: 0.7em;
        line-height: 0;
        position: relative;
        top: -0.45em;
        font-variant-numeric: lining-nums;
        text-decoration: none;
        color: var(--ts-accent);
      }
      .typeset .ts-noteref::before { content: counter(ts-note); }
      .typeset { counter-reset: ts-note; }
      .typeset .ts-noteref { counter-increment: ts-note; }

      /* True bottom-of-page footnotes need an engine that implements the CSS
         footnote spec — WeasyPrint 53+ and Prince do; browsers do not. Where they
         are unavailable this degrades to an endnotes block, which is why the notes
         live in the document rather than in a generated-content property. */
      @supports (float: footnote) {
        .typeset .ts-note {
          float: footnote;
          footnote-display: block;
          font-size: var(--ts-sm);
          line-height: 1.35;
        }
        .typeset .ts-endnotes { display: none; }
      }

      .typeset .ts-endnotes {
        text-align: left;
        margin-top: calc(var(--ts-space) * 2);
        padding-top: var(--ts-space);
        border-top: 0.5pt solid var(--ts-rule);
        font-size: var(--ts-sm);
        line-height: 1.4;
        counter-reset: ts-endnote;
      }
      .typeset .ts-endnotes ol { padding-left: 1.8em; }
      .typeset .ts-endnotes li { margin-bottom: 0.4em; }

      /* Sidenotes: no page-break arithmetic, and the reader never loses their
         place. They cost you a wide right margin. */
      /* The reserved margin must be wide enough for the gap plus the note: 2em + 11em
         here, with an em to spare. */
      .typeset--sidenotes { --ts-measure: 27em; padding-right: 14em; }
      .typeset--sidenotes p { position: relative; }
      .typeset .ts-sidenote {
        position: absolute;

        /* Anchored to the paragraph's own right edge, NOT to `calc(measure + 2em)`.
           The measure is a max-width, so the paragraph is whichever is narrower —
           the measure, or what the container's padding leaves it. Offsetting from the
           measure therefore puts the note in the wrong place whenever the padding is
           the binding constraint, and it lands on top of the text. */
        left: 100%;
        margin-left: 2em;
        width: 11em;
        margin-top: -0.3em;
        font-family: var(--ts-sans);
        font-size: var(--ts-xs);
        line-height: 1.4;
        color: var(--ts-ink-muted);
        text-align: left;
        text-indent: 0;
      }
      .typeset .ts-sidenote::before {
        content: counter(ts-note) " ";
        color: var(--ts-accent);
        font-weight: 600;
      }
// Anchored past the text column's right edge. In Typst the column width is
      // explicit (the `measure` argument), so the offset is taken from it directly.
      #let sidenote(body) = place(
        right,
        dx: 13em,
        dy: -0.3em,
        block(width: 11em, {
          set text(font: sans, size: xs, fill: ink-muted)
          set par(justify: false, leading: leading-for(1.4), first-line-indent: 0pt)
          body
        }),
      )

Sidenotes — on a full page

The measure narrows to 27em and the freed margin carries the notes, anchored to the text column's own right edge. The reader glances right instead of travelling to the foot of the page and back. Shown at 85% because a sidenote layout cannot be judged in half a column.

The measure of a line

Bringhurst puts the ideal measure between 45 and 75 characters, a range narrow enough to be useful and wide enough to survive a change of typeface.Elements, 4th ed., §2.1.2.

The lower bound matters more in a two-column layout, where the column is the measure and there is no slack to give away.Below 40 characters, hyphenation stops being optional.

What the range really encodes is the number of fixations the eye makes per line, which depends on the reader, the difficulty of the prose, and the size of the type relative to the viewing distance. It is a heuristic standing in for a measurement nobody takes, and heuristics right within a factor of 1.5 are worth more than measurements nobody takes.

The cost

Sidenotes are paid for in horizontal space: the text column gives up six ems so the margin can have twelve.Which is why they are forbidden in two columns: no margin is left to give. On a single-column A4 page that is affordable, and the margin was going to be white anyway.

The alternative is a footnote, which asks the reader to travel to the foot of the page and back. A sidenote asks them to glance right. Over forty pages the difference is not small.

The rule that follows

Keep each note short, and keep consecutive notes apart.A long note followed by a close one will collide: they are positioned, not flowed. A note is an aside, not a second argument running down the margin.

A4 at 85%. Sidenotes are positioned, not flowed: keep each one short and consecutive ones apart, or they collide.

21 — Apparatus

Table of contents

Dot leaders work anywhere. Page numbers require the engine to resolve a cross-reference after pagination — native in Typst and LaTeX, Paged Media in CSS. Where it cannot, the leaders render and the number is simply absent. That is the correct failure: a table of contents with wrong page numbers is worse than one with none.

The numbers below are written into the demo by hand so you can see the finished shape. In a real document they come from the engine, and nobody types them.

How it must look

  • Page numbers require the engine to resolve a cross-reference after pagination. Where it cannot, the entry MUST omit the number rather than print a wrong one — a TOC with wrong numbers is worse than one with none.

Entry

fontsans
size9.5pt
alignleft
space after0.35em
decorationnone
leaderdotted 0.5pt rule, colour rule, filling the space between title and number
leader baseline offset-0.15em
page numberresolved after pagination
page number numeralslining tabular
page number colorink_muted

Where unsupported — leaders render, page numbers are omitted

Entry — level 2

indent left1.5em
/* Dot leaders and real page numbers need `target-counter()` — a paged-media
         engine only. In a browser the leaders render and the numbers do not. */
      .typeset .ts-toc { text-align: left; font-family: var(--ts-sans); font-size: var(--ts-sm); }
      .typeset .ts-toc ol { padding-left: 0; counter-reset: ts-toc; }
      .typeset .ts-toc li { margin-bottom: 0.35em; }
      .typeset .ts-toc li::before { content: none; }
      .typeset .ts-toc a {
        display: flex;
        align-items: baseline;
        gap: 0.4em;
        text-decoration: none;
      }
      .typeset .ts-toc a::after {
        content: target-counter(attr(href url), page);
        font-variant-numeric: lining-nums tabular-nums;
        color: var(--ts-ink-muted);
      }
      .typeset .ts-toc .ts-leader {
        flex: 1;
        border-bottom: 0.5pt dotted var(--ts-rule);
        transform: translateY(-0.15em);
      }
      .typeset .ts-toc .ts-toc-2 { padding-left: 1.5em; }
#let toc() = {
        show outline.entry: set text(font: sans, size: sm)
        outline(title: none, fill: repeat(gap: 0.4em)[.], indent: 1.5em)
      }

22 — Apparatus

Bibliography

Hanging indent, always. The surname is the thing being scanned, so it must be the leftmost thing on the entry — an indented first line puts the one useful token where the eye is not looking.

How it must look

References

  1. Bringhurst, Robert. The Elements of Typographic Style. 4th ed. Hartley & Marks, 2012.
  2. Hochuli, Jost. Detail in Typography. Hyphen Press, 2008.
  3. Ruder, Emil. Typographie: A Manual of Design. Niggli, 1967. Still the clearest statement of the case for the grid, and long enough ago to be read without argument.
  4. Tschichold, Jan. The Form of the Book. Hartley & Marks, 1991.
  • Hanging indent, always. The surname is what is being scanned, so it must be the leftmost thing on the entry.

Entry

fontserif
size9.5pt
line height1.4
alignleft
hanging indent1.8em
space after0.55em
markernone
break insideavoid
title styleitalic
/* Hanging indent: the author's surname is the thing being scanned, so it must
         be the leftmost thing on the entry. */
      .typeset .ts-bibliography {
        text-align: left;
        font-size: var(--ts-sm);
        line-height: 1.4;
        padding-left: 0;
        list-style: none;
      }
      .typeset .ts-bibliography li {
        padding-left: 1.8em;
        text-indent: -1.8em;
        margin-bottom: 0.55em;
        break-inside: avoid;
      }
      .typeset .ts-bibliography li::before { content: none; }
      .typeset .ts-bibliography cite { font-style: italic; }

No Typst-specific code. This section states rules rather than settings, or the engine provides the behaviour natively — see spec.json.

23 — Documents

Front matter

Title, subtitle, byline, dateline, abstract — and a colophon to close. The colophon is the note at the back of a book recording how it was made and in what type. It is the one piece of front-and-back matter that is purely for pleasure, and the cheapest thing in this stylesheet to include.

How it must look

On the Measure of a Line

Why the width of a text column is the first decision, and the font is the last

Abstract Every argument about typefaces is downstream of an argument about column width. This essay makes the case that a document set at the wrong measure cannot be rescued by any choice of face, and that a document set at the right one is nearly indifferent to it.

The claim is not original, and it is not complicated…

Set in EB Garamond, with Source Sans 3 for headings and IBM Plex Mono for code. Composed in HTML and rendered to PDF with Paged.js. A4, 11 on 16 points.
  • A colophon closes the document, recording how it was made and in what type. It is the one piece of matter that is purely for pleasure, and the cheapest thing here to include.

Title block

space after33pt
padding bottom11pt
border bottom0.5pt rule

Subtitle

fontsans
weight300
size14pt
line height1.25
colorink_muted
alignleft
wrapbalanced
space after11pt

Byline

fontserif
size11pt
variantall small caps
tracking0.08em
alignleft
space after0

Dateline

fontsans
size9.5pt
colorink_muted
alignleft
space before0.2em

Abstract

fontserif
size10pt
line height1.45
colorink_muted
alignleft
max width30em
space after22pt
labelABSTRACT — sans, 700, 8pt, uppercase, tracking 0.1em, colour ink_faint, on its own line

Colophon

fontserif
styleitalic
size9.5pt
colorink_muted
alignleft
max width26em
space before33pt
padding top11pt
border top0.5pt rule
break beforeavoid
.typeset .ts-titleblock {
        margin-bottom: calc(var(--ts-space) * 3);
        padding-bottom: var(--ts-space);
        border-bottom: 0.5pt solid var(--ts-rule);
      }
      .typeset .ts-titleblock h1 { margin-bottom: 0.25em; }
      .typeset .ts-subtitle {
        text-align: left;
        font-family: var(--ts-sans);
        font-size: var(--ts-h3);
        font-weight: 300;
        line-height: 1.25;
        color: var(--ts-ink-muted);
        margin: 0 0 var(--ts-space);
        text-wrap: balance;
      }
      .typeset .ts-byline {
        text-align: left;
        font-variant-caps: all-small-caps;
        letter-spacing: 0.08em;
        font-size: var(--ts-base);
        margin: 0;
      }
      .typeset .ts-dateline {
        text-align: left;
        font-family: var(--ts-sans);
        font-size: var(--ts-sm);
        color: var(--ts-ink-muted);
        margin: 0.2em 0 0;
      }

      .typeset .ts-abstract {
        text-align: left;
        font-size: 10pt;
        line-height: 1.45;
        color: var(--ts-ink-muted);
        max-width: 30em;
        margin-bottom: calc(var(--ts-space) * 2);
      }
      .typeset .ts-abstract .ts-label {
        font-family: var(--ts-sans);
        font-size: var(--ts-xs);
        font-weight: 700;
        letter-spacing: 0.1em;
        text-transform: uppercase;
        color: var(--ts-ink-faint);
        display: block;
        margin-bottom: 0.3em;
      }

      /* The colophon closes the document: how it was made, and in what. */
      .typeset .ts-colophon {
        text-align: left;
        margin-top: calc(var(--ts-space) * 3);
        padding-top: var(--ts-space);
        border-top: 0.5pt solid var(--ts-rule);
        font-size: var(--ts-sm);
        font-style: italic;
        color: var(--ts-ink-muted);
        max-width: 26em;
        break-before: avoid;
      }
#let title-block(title: none, subtitle: none, author: none, place-date: none) = context {
        let u = text.size
        block(
          below: u * 3, width: 100%,
          inset: (bottom: u),
          stroke: (bottom: 0.5pt + rule-color),
          {
            set par(justify: false, first-line-indent: 0pt)
            if title != none { heading(level: 1, outlined: false, title) }
            if subtitle != none {
              block(below: u, text(font: sans, size: u * 1.27, weight: 300, fill: ink-muted, subtitle))
            }
            if author != none { text(tracking: 0.08em, ..smcp, author) }
            if place-date != none {
              block(above: 0.2em, text(font: sans, size: u * 0.86, fill: ink-muted, place-date))
            }
          },
        )
      }

24 — Documents

Letter

Set ragged right — a letter is addressed to a person, and justification reads as institutional.

The sender block is address data, not a masthead: one style throughout, at body size, in the reading face. Nothing bold, nothing in the sans. A personal letter does not announce itself, and the recipient already knows who writes to them. Omit what you do not use — a personal letter needs no email address and no telephone number.

The signature is the typed name, with room above it to sign. No ruled line: a rule above a name is a form to be filled in, and this is not a form. For the same reason “Enc.” and “P.S.” match the text they introduce exactly — they begin sentences, they do not head sections.

Footnotes belong in a letter as much as in an essay. On a one-page letter the rule and the numbered note at the end are the page foot, so it reads correctly even in an engine that cannot set true footnotes.

How it must look

Adilson Carvalho

10 Wentworth Avenue

Surry Hills NSW 2010

Australia

To The Registrar
Institute of Typographic Studies
88 Rundle Street
Adelaide SA 5000

27 August 2026

St Monica, mother of St Augustine Bishop, ora pro nobis

Dear Registrar,

I am writing about the setting of the Institute’s annual report, which arrived this morning and which I read with more attention to its margins than to its contents. I hope that is taken in the spirit intended.

The text column runs to something near a hundred and ten characters. By the second page I had lost my place twice, and by the fourth I had stopped reading and started measuring.

This is a cheap problem to fix, and I would be glad to send the specification I use for my own documents.

Yours sincerely,

Adilson Carvalho

Enc. typeset.css; two specimen pages

P.S. The tables were excellent — tabular figures throughout, which is more than most annual reports manage.

  1. Measured on page four, between the outer margins: 168mm at a 10pt body, which is about 112 characters.
  • A letter is set ragged right. It is addressed to a person, and justification reads as institutional.
  • The sender block is address data, not a masthead: one style throughout, at body size, in the reading face. Nothing bold, nothing in the sans. A personal letter does not announce itself.
  • Address blocks are line-broken data, not prose: tighter leading, never justified, line breaks as authored.
  • The signature is the typed name with room above it to sign. No ruled line — a rule is a form to be filled in.
  • A letter carries no running head and no folio.
  • Footnotes belong in a letter as much as in an essay. Where the engine cannot set them at the page foot, the rule plus the numbered note at the end of a one-page letter reads as a footnote area regardless.

Letter page

margin top mm32
margin bottom mm28
margin sides mm25
running headnone
folionone

The deeper top margin puts the letterhead where an envelope window expects it.

Sender block

fontserif
weight400
size11pt
line height1.35
colorink
alignleft
line breaksas authored — name, street, suburb and postcode, country
space after27.5pt

Every line is the same style, including the name. Setting the name larger or bolder turns a letter into stationery.

Omit what you do not use. A personal letter needs no email address and no telephone number.

Address block

fontserif
size11pt
line height1.35
stylenormal
alignleft
line breaksas authored
space after16.5pt
labelsans, 8pt, uppercase, tracking 0.1em, colour ink_faint, on its own line

Date line

numeralsoldstyle
alignleft
space after16.5pt

Line under the date

fontserif
styleitalic
size11pt
colorink
alignleft
space before0.1em — it belongs to the date, and takes no gap of its own
space after16.5pt

A dedication, a feast, a devotion — whatever the writer puts under the date. Italic, at body size, immediately beneath.

Salutation

alignleft
space after11pt

Closing

alignleft
space before16.5pt

Signature block

fontserif
size11pt
alignleft
space before33pt — the room to sign
rulenone
break insideavoid
signature image max height16mm

The typed name only. A ruled line above a name is a form; a letter is not a form.

Enclosures

fontserif
size11pt
alignleft
space before22pt
labelsame style as the text it introduces — no small caps, no weight change

Same reasoning as the postscript: "Enc." introduces a sentence, it does not head a section.

Postscript

fontserif
size11pt
alignleft
space before11pt
labelsame style as the text it introduces — no small caps, no weight change

A postscript is a sentence that happens to begin with "P.S.". The label is not a heading.

Footnote

markeras the note marker in Apparatus — superscript, accent, no brackets
positionfoot of the page where the engine can; otherwise after the signature block
fontserif
size9.5pt
separator0.5pt rule above the notes area
headingnone

Where unsupported — a numbered note after the signature, under a rule — which on a one-page letter is the page foot anyway

/* The sender block is address data, not a masthead: one style throughout, at
         body size, in the reading face. A personal letter does not announce itself —
         the recipient knows who writes to them. Nothing here is bold, and nothing is
         set in the sans, so the block recedes and the letter starts sooner. */
      .typeset .ts-letterhead {
        text-align: left;
        font-size: var(--ts-base);
        line-height: 1.35;
        color: var(--ts-ink);
        margin: 0 0 calc(var(--ts-space) * 2.5);
      }
      .typeset .ts-letterhead p {
        margin: 0;
        text-indent: 0;
      }

      /* Address blocks are not prose: they are line-broken data. */
      .typeset .ts-address {
        text-align: left;
        font-style: normal;
        line-height: 1.35;
        margin-bottom: calc(var(--ts-space) * 1.5);
      }
      .typeset .ts-address .ts-label {
        display: block;
        font-family: var(--ts-sans);
        font-size: var(--ts-xs);
        letter-spacing: 0.1em;
        text-transform: uppercase;
        color: var(--ts-ink-faint);
        margin-bottom: 0.25em;
      }

      .typeset .ts-date {
        margin-bottom: calc(var(--ts-space) * 1.5);
        font-variant-numeric: oldstyle-nums;
        text-align: left;
      }

      /* A line under the date, in italic — a dedication, a feast, a devotion. It
         belongs to the date rather than following it, so it climbs back over the
         date's gap and carries that gap itself. Written against the same token as the
         date's margin so the two cannot drift apart.
      
         Deliberately not `.ts-date:has(+ .ts-date-note)`: Paged.js's CSS parser does
         not understand `:has()` any more than it understands `:is()`, and a selector
         it cannot parse aborts pagination outright. */
      .typeset .ts-date-note {
        margin: calc(var(--ts-space) * -1.5 + 0.1em) 0 calc(var(--ts-space) * 1.5);
        font-style: italic;
        text-indent: 0;
      }
      .typeset .ts-salutation { margin-bottom: var(--ts-space); text-align: left; }
      .typeset .ts-closing { margin: calc(var(--ts-space) * 1.5) 0 0; text-align: left; }

      /* The typed name, with room above it to sign. No rule: a ruled line is a form
         to be filled in, and this is a letter. */
      .typeset .ts-signature {
        text-align: left;
        margin-top: calc(var(--ts-space) * 3);
        text-indent: 0;
        break-inside: avoid;
      }
      .typeset .ts-signature img { display: block; max-height: 16mm; margin-bottom: 0.2em; }

      /* Same reasoning as the postscript: "Enc." introduces a sentence, it does not
         head a section. Left as the only small-caps label in a letter it was the one
         thing on the page shouting. */
      .typeset .ts-enclosures {
        text-align: left;
        margin-top: calc(var(--ts-space) * 2);
        font-size: var(--ts-base);
        text-indent: 0;
      }
      /* A postscript is a sentence that happens to begin with "P.S." — the label is
         not a heading, so it matches the text it introduces exactly. */
      .typeset .ts-ps {
        text-align: left;
        margin-top: var(--ts-space);
        font-size: var(--ts-base);
        text-indent: 0;
      }
#let letter-page(doc) = {
        set page(
          margin: (top: 32mm, bottom: 28mm, x: 25mm),
          header: none,
          footer: none,
        )
        doc
      }

25 — Documents

Pagination utilities

The small set of overrides reached for while proofing a real document: force a break, forbid one, stop a hyphen, tie two words together. Every one is a manual decision about a specific page, not a style. The last of them tells the output device not to strip backgrounds and rules to save ink.

How it must look

.ts-page-break-before
Start this element on a new page. For chapter openings.
.ts-keep-together
Never split this element across pages. For a short table, a signature block, a callout.
.ts-tie
Keep a figure with its unit: 11 pt, §2.1, Fig. 1.
.ts-print-only / .ts-screen-only
For the URL list a printed page needs and a screen does not, and the navigation a screen needs and a page does not.
  • The small set of overrides reached for while proofing a real document. Every one of them is a manual decision about a specific page, not a style.

Break before

effectstart this element on a new page

Chapter openings.

Break after

effectstart the next element on a new page

Keep together

effectnever split this element across pages

A short table, a signature block, a callout.

No hyphenation

hyphenationmanual

Tie

effectno line break inside

A figure with its unit, an initial with a surname, a section mark with its number.

Print only / screen only

effectpresent in one medium, absent in the other

The URL list a page needs and a screen does not; the navigation a screen needs and a page does not.

Exact colour

effectbackgrounds and rules survive the output device's ink-saving

Without it a print dialog can strip every rule in a table.

.ts-page-break-before { break-before: page; }
      .ts-page-break-after  { break-after: page; }
      .ts-page-break-avoid  { break-inside: avoid; }
      .ts-keep-together     { break-inside: avoid; page-break-inside: avoid; }
      .ts-no-hyphens        { hyphens: manual; }
      .ts-nowrap            { white-space: nowrap; }

      /* Non-breaking spaces belong in the markup, but these catch the common cases:
         a figure and its unit, an initial and a surname. */
      .ts-tie { white-space: nowrap; }

      @media print {
        .ts-screen-only { display: none !important; }
      }
      @media screen {
        .ts-print-only { display: none !important; }
      }

      /* Force backgrounds and rules to survive the print dialog's ink-saving. */
      @media print {
        .typeset,
        .typeset * {
          -webkit-print-color-adjust: exact;
          print-color-adjust: exact;
        }
      }
#let keep-together(body) = block(breakable: false, body)
      #let tie(body) = box(body)