Typography

Every rule the theme's generated typography stylesheet contains, quoted beside markup it applies to. `mantra_starter` styles formatted text with UnoCSS's `presetTypography` under `selectorName: 'text-formatted'`, so what styles a body of text is generated, is in no file anybody edits, and can only be read by opening `dist/unocss.css` after a build -- this page is that file, laid out. It also covers the thirty-six custom properties the colour scheme passes through, the generated rules that match nothing, and the twelve places where these rules meet the theme's bare element styles. The content is made up and nothing is saved.

The block

Four declarations and thirty-six custom properties, all on `.text-formatted` itself. The measure is the one to know: `max-width: 65ch` is set on the block, so the class does not only style what is inside it, it sizes the element it is on. Every frame on this page is as wide as it is because of that one declaration. The colour is the only thing the theme overrides -- `libraries/global/base/typography.formatted.css` redefines `--un-prose-body` as `var(--color-content)` and `--un-prose-bullets` as `var(--list-bullet-color)`, and wins because `base.css` is aggregated after `unocss.css`. The rest of that file is commented out.

A paragraph long enough to reach the measure the class sets on itself, so that the edge of the frame around this sample is the 65ch the preset asks for rather than anything the page put there.

The second paragraph shows the spacing: 1.25em top and bottom, which against a 1rem font size and a 1.75 line height is the rhythm everything else on this page is set against.

.text-formatted
color: var(--un-prose-body); max-width: 65ch
.text-formatted
font-size: 1rem; line-height: 1.75
.text-formatted
--un-prose-body … 36 custom properties, listed at the foot of this page
:where(p)
margin-top: 1.25em; margin-bottom: 1.25em

Headings

`h1` to `h4` are styled and `h5` and `h6` are not -- the preset has no rule for either, so they arrive at whatever the browser and the rest of the theme leave them at, which is why the two at the bottom of this sample are smaller than the body text above them. Sizes are in `em`, so a heading is sized against the block rather than against the root, and the `strong` and `code` inside a heading are handled separately at every level: `strong` takes the weight up one step and both drop their own colour back to `inherit` so they do not fight the heading colour.

Heading one, with strong and code

The margin above an h1 is zero, whatever it follows.

Heading two, with strong and code

A paragraph directly after a heading loses its own top margin.

Heading three, with strong and code

The heading owns the space below it.

Heading four, with strong

An h4 is the last one with a rule. It has no font size of its own, only a weight, a line height and margins.

Heading five is not styled by the preset
Heading six is not styled by the preset
:where(h1)
color: var(--un-prose-headings); margin-top: 0; margin-bottom: .888889em; font-size: 2.25em; font-weight: 800; line-height: 1.11111
:where(h1 strong)
color: inherit; font-weight: 900
:where(a code), :where(h1 code)
color: inherit
:where(h2)
color: var(--un-prose-headings); margin-top: 2em; margin-bottom: 1em; font-size: 1.5em; font-weight: 700; line-height: 1.33333
:where(h2 strong)
color: inherit; font-weight: 800
:where(h2 code)
color: inherit; font-size: .875em
:where(h3)
color: var(--un-prose-headings); margin-top: 1.6em; margin-bottom: .6em; font-size: 1.25em; font-weight: 600; line-height: 1.6
:where(h3 strong)
color: inherit; font-weight: 700
:where(h3 code)
color: inherit; font-size: .9em
:where(h4)
color: var(--un-prose-headings); margin-top: 1.5em; margin-bottom: .5em; font-weight: 600; line-height: 1.5
:where(h4 strong)
color: inherit; font-weight: 700
:where(h4 code), :where(blockquote code), :where(thead th code)
color: inherit
:where(h5), :where(h6)
no rule

Paragraphs, and the lead

One element and one class. `lead` is the only class the preset styles, and it is matched as `[class~="lead"]` rather than as an element, so it can go on anything -- the sample puts it on a paragraph because that is what it is for. Note that its margins are 1.2em of its own 1.25em font size, which is 1.5rem, wider than the 1.25em of an ordinary paragraph.

A lead paragraph, one and a quarter times the body size, in the lead colour, on a line height of its own.

An ordinary paragraph after it, at the body size and the body colour, for the comparison.

And a second one, so the space between two paragraphs is visible next to the space under the lead.

:where(p)
margin-top: 1.25em; margin-bottom: 1.25em
:where([class~="lead"])
color: var(--un-prose-lead); margin-top: 1.2em; margin-bottom: 1.2em; font-size: 1.25em; line-height: 1.6

Inline elements

The backticks around inline code are the thing to know here: the preset puts them there with `content: "`"` on `::before` and `::after`, so a `code` element in a body of text renders with a pair of literal backticks around it that are in no field anybody edited. They are suppressed inside `pre` and nowhere else. `kbd` is styled but its shadow is not painted -- see the last section but two for why. `em` has no rule at all, so its italic is the browser's.

A paragraph with a link in it, a link that contains strong text, some bold text on its own, some emphasis, a piece of inline code, a link around code, and a key to press: Ctrl S.

:where(a)
color: var(--un-prose-links); font-weight: 500; text-decoration: underline
:where(strong)
color: var(--un-prose-bold); font-weight: 600
:where(a strong), :where(blockquote strong), :where(thead th strong)
color: inherit
:where(code)
color: var(--un-prose-code); font-size: .875em; font-weight: 600
:where(code)::before, :where(code)::after
content: "`"
:where(a code)
color: inherit
:where(kbd)
color: var(--un-prose-kbd); box-shadow: 0 0 0 1px rgb(var(--un-prose-kbd-shadows) / 10%), 0 3px 0 rgb(var(--un-prose-kbd-shadows) / 10%); padding: .1875em .375em; border-radius: .3125rem; font-family: inherit; font-size: .875em; font-weight: 500
:where(em)
no rule

Lists

The preset restates `list-style-type` on `ol` and `ul`, which matters because `presetWind4`'s reset -- which would have removed the markers -- is switched off in `uno.config.js`, so these are re-declaring what the browser already did. What is not restated is the nesting: the browser alternates disc, circle and square on its own and the preset does not touch it, so a nested list keeps the marker the browser gives it. The nine `ol[type]` rules are the interesting part, because CKEditor writes that attribute when an author picks a numbering style, and they come in pairs -- one case-insensitive, one with the `s` flag -- so that `type="a"` and `type="A"` stay distinguishable. Markers are coloured through `::marker`, and `--un-prose-bullets` is the second of the two properties the theme overrides.

  • An unordered list item, whose marker takes the bullets colour.
  • A second item, showing the spacing between them.
    • A nested list, at three quarters of an em above and below.
    • Its marker is the browser's, not the preset's.
  1. An ordered list item, whose marker takes the counters colour.
  2. A second item.
  1. Upper alpha
  2. Second
  1. Lower alpha
  2. Second
  1. Upper roman
  2. Second
  1. Lower roman
  2. Second
  1. Decimal
  2. Second
A description term
Its description, indented by the same 1.625em a list is.
A second term
The term takes the headings colour, not the body colour.
:where(ul)
margin-top: 1.25em; margin-bottom: 1.25em; padding-inline-start: 1.625em; list-style-type: disc
:where(ol)
margin-top: 1.25em; margin-bottom: 1.25em; padding-inline-start: 1.625em; list-style-type: decimal
:where(ol[type="A"]), :where(ol[type="A" s])
list-style-type: upper-alpha
:where(ol[type="a"]), :where(ol[type="a" s])
list-style-type: lower-alpha
:where(ol[type="I"]), :where(ol[type="I" s])
list-style-type: upper-roman
:where(ol[type="i"]), :where(ol[type="i" s])
list-style-type: lower-roman
:where(ol[type="1"])
list-style-type: decimal
:where(ol > li)::marker
color: var(--un-prose-counters); font-weight: 400
:where(ul > li)::marker
color: var(--un-prose-bullets)
:where(li)
margin-top: .5em; margin-bottom: .5em
:where(ol > li), :where(ul > li)
padding-inline-start: .375em
:where(ul ul, ul ol, ol ul, ol ol)
margin-top: .75em; margin-bottom: .75em
:where(dl)
margin-top: 1.25em; margin-bottom: 1.25em
:where(dt)
color: var(--un-prose-headings); margin-top: 1.25em; font-weight: 600
:where(dd)
margin-top: .5em; padding-inline-start: 1.625em

Blockquotes

A blockquote is given a `quotes` value and then two pseudo-elements that spend it: `open-quote` before the first paragraph and `close-quote` after the last. So a quotation renders with curly quotation marks around it that are not in the content, and a blockquote holding two paragraphs gets one pair around the whole thing rather than a pair each -- which is what `:first-of-type` and `:last-of-type` are for. The border is on the inline start edge, so it moves to the right in a right-to-left language on its own.

The first paragraph of a quotation, which gets the opening quotation mark and nothing else, and is set in italic at a weight of 500.

The last paragraph, which gets the closing mark. A strong and a code in here both drop back to the quote colour rather than keeping their own.

:where(blockquote)
color: var(--un-prose-quotes); border-inline-start-width: .25rem; border-inline-start-color: var(--un-prose-quote-borders); quotes: "\201C" "\201D" "\2018" "\2019"; margin-top: 1.6em; margin-bottom: 1.6em; padding-inline-start: 1em; font-style: italic; font-weight: 500
:where(blockquote p:first-of-type)::before
content: open-quote
:where(blockquote p:last-of-type)::after
content: close-quote
:where(blockquote strong)
color: inherit
:where(blockquote code)
color: inherit

Code blocks

`pre` is the only element the preset gives a background to, and it is a dark one whatever the rest of the page is doing: `--un-prose-pre-bg` is a fixed slate, so a code block is dark on a light page by design rather than by accident. Everything the inline `code` rule did is then undone on `pre code` -- the size, the weight, the colour, the padding, the background and both backticks -- so that a `code` inside a `pre` is a plain run of text and the block around it carries the styling. This is the pairing `basic_pages` shows with Prism's `language-*` classes on it; here it is the bare form.

A block of code, with inline code above it for the contrast:

$build['about'] = [
  '#type' => 'container',
  '#attributes' => ['class' => ['ui-examples-about', 'text-formatted']],
];

A line long enough to need the horizontal scroll the block is given, which is the only overflow rule in the whole stylesheet:

./vendor/bin/drush @ddev.theme cget system.theme && ./vendor/bin/drush @ddev.theme cget node.settings use_admin_theme
:where(pre)
color: var(--un-prose-pre-code); background-color: var(--un-prose-pre-bg); padding: .857143em 1.14286em; border-radius: .375rem; margin-top: 1.71429em; margin-bottom: 1.71429em; font-size: .875em; font-weight: 400; line-height: 1.71429; overflow-x: auto
:where(pre code)
font-weight: inherit; color: inherit; font-size: inherit; font-family: inherit; line-height: inherit; background-color: transparent; border-width: 0; border-radius: 0; padding: 0
:where(pre code)::before, :where(pre code)::after
content: none

Tables

Nineteen of the seventy-odd rules are about tables, and the shape they describe is a table with no vertical rules and no outer border: a line under the head, a line under each body row except the last, a line over the foot, and nothing else. The horizontal padding is dropped on the first and last cell of every row, so the text of the first column lines up with the paragraph above the table rather than being indented by a cell. Note `text-align: start` rather than `left`, which is the pattern through the whole preset -- every directional property here is logical. This is also the section where the theme disagrees most: `elements.css` gives every `table`, `th` and `td` a solid border, and the preset has no rule to take it away.

Element Rules colour
thead th4headings
tbody td5inherited
tfoot td4inherited
Total13 
:where(table)
table-layout: auto; width: 100%; margin-top: 2em; margin-bottom: 2em; font-size: .875em; line-height: 1.71429
:where(thead)
border-bottom-width: 1px; border-bottom-color: var(--un-prose-th-borders)
:where(thead th)
color: var(--un-prose-headings); vertical-align: bottom; padding-inline-end: .571429em; padding-bottom: .571429em; padding-inline-start: .571429em; font-weight: 600
:where(thead th:first-child)
padding-inline-start: 0
:where(thead th:last-child)
padding-inline-end: 0
:where(thead th strong)
color: inherit
:where(thead th code)
color: inherit
:where(tbody tr)
border-bottom-width: 1px; border-bottom-color: var(--un-prose-td-borders)
:where(tbody tr:last-child)
border-bottom-width: 0
:where(tbody td)
vertical-align: baseline
:where(tfoot)
border-top-width: 1px; border-top-color: var(--un-prose-th-borders)
:where(tfoot td)
vertical-align: top
:where(tbody td, tfoot td)
padding: .571429em
:where(tbody td:first-child, tfoot td:first-child)
padding-inline-start: 0
:where(tbody td:last-child, tfoot td:last-child)
padding-inline-end: 0
:where(th, td)
text-align: start

Figures and media

Media gets margins and nothing else -- no width, no border, no radius. The rule worth understanding is the pair `figure` and `figure > *`: the figure takes the 2em above and below, and then every child of it is zeroed, so an image inside a figure does not add its own 2em to the figure's and the caption sits against the image rather than a line below it. `picture > img` is the same trick one level down. There is no `max-width` on an image anywhere in the preset; what keeps the one below inside its frame is the theme's `img { max-width: 100% }` in `elements.css`. The video has no source, so what it shows is its poster -- which is all that is needed to see the margins.

A bare image, with 2em above and below it:

A placeholder, 320 by 160.

The same image inside a figure, whose margins replace the image's:

A placeholder, 320 by 160.
A caption, in the captions colour at seven eighths of the body size.

A picture, which the preset makes a block:

A placeholder, 320 by 160.

And a video, showing its poster:

:where(img)
margin-top: 2em; margin-bottom: 2em
:where(picture)
margin-top: 2em; margin-bottom: 2em; display: block
:where(picture > img)
margin-top: 0; margin-bottom: 0
:where(video)
margin-top: 2em; margin-bottom: 2em
:where(figure)
margin-top: 2em; margin-bottom: 2em
:where(figure > *)
margin-top: 0; margin-bottom: 0
:where(figcaption)
color: var(--un-prose-captions); margin-top: .857143em; font-size: .875em; line-height: 1.42857

Separators, and the collapse after them

`hr + *`, `h2 + *`, `h3 + *` and `h4 + *` all zero the top margin of whatever follows, which is how a heading comes to own the space under it rather than sharing it with the paragraph below. It is worth reading as one rule with four subjects, because it is the only place in the preset where the spacing of an element depends on what precedes it. The `hr` itself is the clearest case on this page of the theme winning: the preset asks for a 1px top border in the hr colour, and `elements.css` has `hr { @apply bg-black h-1 m-y-8 border-none }` -- `border-none` sets `border-style: none`, so the border the preset draws is never painted and what you see is the theme's 4px black bar, with the preset's 3em margins because those do win on specificity.

A paragraph before the rule.


The paragraph after it has no top margin of its own: all the space above comes from the rule.

A heading two

Same again after a heading, at every level from two to four.

:where(hr)
border-color: var(--un-prose-hr); border-top-width: 1px; margin-top: 3em; margin-bottom: 3em
:where(hr + *), :where(h2 + *), :where(h3 + *), :where(h4 + *)
margin-top: 0

The escape hatch

Every descendant rule in the stylesheet ends in `:not(:where([class~="not-text-formatted"], [class~="not-text-formatted"] *))`. It is generated from the preset's default, which is the selector name with `not-` in front of it, and its effect is that a subtree marked with that class is dropped out of the prose styling entirely -- itself and everything under it. That is the supported way to put a piece of built markup, a rendered entity or a component, inside a body of formatted text without the prose rules reaching into it. Nothing has to be added to the theme to use it: the class is already in every selector. Note that it is written as `:where()` inside the `:not()`, so it costs no specificity and adding it changes nothing about what overrides what.

A paragraph in the prose, with code and a link.

A paragraph inside the escape hatch, with code and a link. No margin, no colour, no backticks, no link weight: none of the rules reach it.

  • The list keeps the browser's padding, not the preset's.
  • And the browser's marker colour.

And the prose resumes after it.

:where(…):not(:where([class~="not-text-formatted"], [class~="not-text-formatted"] *))
the guard on every descendant rule

The custom properties

Thirty-six properties, all defined on `.text-formatted` itself, and the whole colour scheme of the stylesheet passes through them -- no rule above names a colour directly. Each is defined twice, once plainly and once with an `invert-` prefix, and the inverted half is what `text-formatted-invert` would switch to on a dark ground. That class is not generated in this build, so the second column here is the only place those values are visible. Two of the plain values are overridden by the theme and are shown as the theme sets them; the rest are the preset's defaults, which are Tailwind's grays.

Property Value Inverted Spent on
--un-prose-bodyThe block itself. Overridden by the theme as var(--color-content).
--un-prose-headingsh1 to h4, dt, thead th.
--un-prose-leadAnything classed lead.
--un-prose-linksa.
--un-prose-boldstrong.
--un-prose-countersThe ::marker of an ordered list.
--un-prose-bulletsThe ::marker of an unordered list. Overridden by the theme as var(--list-bullet-color).
--un-prose-hrThe border of a rule, which the theme then removes.
--un-prose-quotesblockquote.
--un-prose-quote-bordersThe inline start border of a blockquote.
--un-prose-captionsfigcaption.
--un-prose-kbdThe text of a kbd.
--un-prose-kbd-shadowsThe shadow of a kbd, which is never painted.
--un-prose-codeInline code, and code inside a pre by inheritance.
--un-prose-pre-codeThe text of a pre.
--un-prose-pre-bgThe background of a pre.
--un-prose-th-bordersUnder the thead, and over the tfoot.
--un-prose-td-bordersUnder each row of the tbody.
.text-formatted
--un-prose-body: #364153; --un-prose-invert-body: #d1d5dc; … 36 in all
base.css
.text-formatted { --un-prose-body: var(--color-content); --un-prose-bullets: var(--list-bullet-color) }

What is generated and does nothing

Three things in the built stylesheet are inert, and none of them fails loudly. The first is the group of rules the preset keys with a leading child combinator -- `> :first-child`, `> :last-child`, `> ul > li p` and the four `> ol|ul > li > p:first-child|last-child` -- which come out of the build as `:where()` with an empty selector list. An empty forgiving selector list matches nothing, so the rules are in the file and never apply. The visible consequence is the one that matters most: the first child of a block of formatted text is meant to lose its top margin and the last child its bottom margin, and here neither does, so every `.text-formatted` block carries a margin inside its own box. All seven of those rules collapse onto the same empty selector, so only the last two survive the merge at all. The second is the `kbd` shadow: `--un-prose-kbd-shadows` is a hex colour and the rule spends it as `rgb(var(--un-prose-kbd-shadows) / 10%)`, which is not valid syntax, so the whole `box-shadow` declaration is dropped and a `kbd` renders with its padding and radius but no ring. The third is a matter of scale rather than of breakage: the preset can emit `sm`, `lg`, `xl` and `2xl` sizes and an inverted scheme, but UnoCSS only generates a class something asks for, `content.filesystem` in `uno.config.js` scans Twig and CSS and not PHP, and no Twig template names any of them -- so the base size is the entire stylesheet. A page that wanted `text-formatted-lg` would have to put it in `libraries/global/utilities/safelist.txt` and rebuild the theme.

The first child of this block. Its top margin is 1.25em, and the rule that was meant to remove it is in the stylesheet and matches nothing -- which is why there is more space between this line and the top of the frame than the frame's own padding.

Press Ctrl to see the kbd without its shadow.

The last child, whose bottom margin is there for the same reason.

:where()
margin-top: 1.25em; margin-bottom: 1.25em
:where()
margin-top: 0; margin-bottom: 0
:where(kbd) box-shadow
0 0 0 1px rgb(var(--un-prose-kbd-shadows) / 10%), 0 3px 0 rgb(var(--un-prose-kbd-shadows) / 10%)
.text-formatted-sm|lg|xl|2xl
not generated: nothing scanned asks for it
.text-formatted-invert
not generated: nothing scanned asks for it

Where the theme meets the preset

Nothing above renders in isolation. `libraries/global/base/elements.css` styles the same elements as bare tags, and the arithmetic is always the same: a generated rule is `.text-formatted :where(…)`, one class and no more, because `:where()` costs nothing -- so it beats a bare element selector on every property both of them set, and leaves every property only the element rule sets alone. That is why a code block here has the preset's colours and the theme's `display: block`, and why an `hr` has the preset's margins and the theme's black bar. Worth checking against this list before adding a rule to either file, because a declaration added to `elements.css` for an element in this stylesheet will not reach formatted text.

A paragraph: the theme asks for my-2 and the preset for 1.25em. The preset wins.

  • A list: the theme asks for ps-4, the preset for 1.625em. The preset wins.
A code block: the colours and the padding are the preset's,
the display: block is the theme's, because the preset never sets it.

A rule: the margins are the preset's, the black bar is the theme's, and the 1px border the preset asks for is never painted because the theme set border-style to none.

p { my-2 }
preset wins: 1.25em
ol { ps-6 }, ul { ps-4 }
preset wins: 1.625em
figure { m-0 }
preset wins: 2em
dt { font-bold }
preset wins: 600
dd { mb-3 }
both apply: the preset sets no bottom margin
strong { font-semibold }
no contest: both are 600
code { px-1 rounded-sm bg-neutral-100 }
both apply: the preset sets no background or padding on inline code
pre code { d-block px-2 text-sm }
preset wins on padding and size; display: block is the theme's
img { d-block max-w-full h-auto }
both apply: the preset sets margins only
hr { bg-black h-1 m-y-8 border-none }
preset wins on margins; border-style: none hides the preset's border
table, th, td { border-1 border-solid }
both apply: the preset draws no vertical rules and does not remove these
th, td { align-top text-left p-2 }
preset wins on all three