Skip to content

src/prose - 3 files

Prose

For HTML you did not write a class on: whatever a Markdown renderer, a CMS field or a mail template emits. Three files, so a page that wants only code blocks pays for only code blocks.

The markup panels below are read out of the live examples by site.js. With scripting off the examples still render and the panels stay empty.

A full article

Every element the three files touch, in one document, rendered rather than listed. One class appears in it, on the wrapper. Everything inside is the plain HTML a renderer emits: no class on a heading, a list, a table, a figure or a footnote. If this reads correctly then the claim the block makes is true, and if it does not then the bug is visible here rather than in someone's blog.

One class, on the wrapper, and nothing else

Measuring a stylesheet

A framework that cannot be measured cannot be trusted, and most of what is published about size is a number with no unit of comparison attached to it. This document is the long-form test case: it exists so that the reading surface can be judged the way a reader meets it, in running text, rather than as a grid of isolated components.

What gets measured

Three figures, and they are not interchangeable. The authored file is what a contributor reads. The built file is what a browser downloads. The transfer size is what the network moves, and it is the only one of the three another project's published figure can be compared against.

The authored file

Comments carry the contrast measurements and the reasons, which is most of their value and, with no build step, also most of their bytes. Deleting them would make the number smaller and the file worse.

A fourth-level heading

Present so the scale can be seen closing rather than described as closing. By this depth the step ratio is near enough to the body size that a heading reads as emphasis instead of as a new section.

A fifth-level heading
And a sixth

Inline runs, all of which a renderer emits without a class: strong for importance, emphasis for stress, a marked run, small print, a deletion next to an insertion, a variable n, the W3C as an abbreviation with a title, water as H2O, a square metre as m2, inline --bs-prose-measure, the sample output exit status 0, the keys Ctrl and C, and a link back to the top of this section.

Lists

An unordered list, with a nested one inside the second item:

  • Markers sit outside the text column, so the indent has to clear the widest marker the document can produce.
  • A nested list takes the item gap rather than the block gap:
    • because it is part of its parent item
    • and not a new block of its own
  • Item spacing is tighter than paragraph spacing, which is what makes a list read as one object.

An ordered list, whose three-digit ordinals the indent is sized for:

  1. Read the contract.
  2. Measure the thing you are about to claim.
  3. Publish the number with the form it was measured in.

And a definition list:

Authored
The file as a contributor reads it, comments included.
Built
The same CSS with comments and redundant whitespace removed.
Transfer
What the network actually moves, which is the built file compressed.

A quotation

Contrast is a correctness property, not a preference. If a pairing cannot meet the floor, the answer is not to ship it and document the exception.

From the build contract

The same file also draws a pull quote, which sets larger and drops the accent edge:

A number with no unit of comparison is not a measurement.

Code

A block is <pre><code>, and it carries the three attributes CSS cannot supply, because a box that scrolls has to be reachable from the keyboard:

bun run size
bun run contrast
bun test test/

A table

Wrapped in the scroll region the contract asks for, which is what makes it an addressable box a keyboard can reach. At this width you are watching the other half of the answer: the cell rule lets the columns compress to fit rather than taking the page sideways, and the wrapper takes over only when a cell can no longer compress.

The four core files, built and transferred.
File Built Sent Layer
reset 2,180 908 reset
tokens 6,240 1,904 tokens
base 4,010 1,377 base
type 5,880 1,566 three

A figure

Three blocks: one tall block in the accent colour beside two shorter blocks, one periwinkle and one green.
A caption sits under the thing it describes. The corner treatment comes from the block, the intrinsic size comes from the attributes, and the attributes are what stop the page reflowing when the image lands.

An aside and a disclosure

Why there is no highlighter in this framework

Because the parser is a build-time decision and this has no build step. The file decides what seven colours mean and one rule of yours wires whichever parser you already run to them.

A claim worth a note gets one1, and so does the one after it2. Following either moves the browser to the note and marks it as the thing you were sent to.

  1. Measured with gzip at level 9 on the built file. Back to the text
  2. The roles are DPUB-ARIA, matched instead of a generator's class names so the treatment survives a change of renderer. Back to the text
Show codeHide code

Two classes in that document are not on the wrapper, and both are named in the contract rather than hidden: bs-prose__scroll, which no renderer emits and which the author has to write around a wide table, and bs-quote--pull, because a pull quote is an editorial decision and not something a renderer can infer. The paragraph inside the pull quote carries bs-text-h3 for the reason the quotations section below explains.

That document opens with an h1, which makes two on this page, and it is here deliberately rather than by oversight: a sample that skips the level a real article starts at is not a sample of a real article. The outline algorithm that would have scoped it to its article was never implemented by any browser, so on a page of your own the document's own heading is the page's only h1 and this problem does not arise.

Prose block

One class on the wrapper and nothing on any child. It deliberately does not restate the type scale, the measure caps or the vertical rhythm, all of which core already owns at zero specificity. It adds only the parts that make sense inside a reading column: list indentation, table rules, definition lists, media treatment, footnotes, and the column itself.

src/prose/prose.css, adds 2,910 B gzip

Rendered from Markdown

Not one element below carries a class. The wrapper is the only styling decision, which is what makes this usable against output you do not control.

  • An unordered list, with its markers intact.
  • A nested list:
    • which gets its own indentation and spacing

Inline code, a link, and a rule:


A caption sits under the thing it describes.
Show codeHide code

A table wider than the column will push the page sideways at 320px, so wrap it in bs-prose__scroll with role="region", tabindex="0" and a name. Every table carries a caption, or an aria-label: a table with neither is a grid of numbers with no name. Footnotes are matched on the DPUB-ARIA roles rather than on a generator's class names, so the renderer must emit role="doc-noteref", role="doc-endnotes" and role="doc-backlink".

Code

Inline code, keyboard keys, sample output and code blocks. Element selectors are wrapped in :where() so raw code and pre in generated markup look right without a class anywhere, and any rule of yours still wins over it.

src/prose/code.css, adds 2,297 B gzip

Inline --bs-card-bg, sample output exit status 0, and a block:

bun run size
bun run contrast
bun test test/
Show codeHide code

A block that can overflow scrolls, and anything that scrolls has to be reachable from the keyboard, which SC 2.1.1 requires and CSS cannot grant. That is why every pre on this site carries tabindex="0", role="region" and a label. Language is content, not decoration: put it in the label or a visible heading, never in generated content, which is invisible to search, untranslatable and read out inconsistently.

Syntax tokens

No highlighter ships here. This file decides what the colours mean, you decide which parser produces them, and one rule of your own wires the two together. The set is built from hue plus weight plus slant rather than from seven new colours, because the palette is fixed and the six theme-aware inks are the only values measured AA against all eight surfaces in both themes.

Seven roles, no new colours
/* the seven token roles */
export function measure(file) {
  const level = 9;
  return gzip(file, 'level 9');
}
Show codeHide code

Wiring a highlighter is one rule: .token.comment { color: var(--bs-syntax-comment, var(--bs-text-muted)); }. The marked line carries a wash and a leading edge, so it survives greyscale and a monochrome print rather than relying on the tint alone.

Quotations

Block quotations, pull quotes and attribution. cite is the title of a work, not the name of a person. The specification is explicit about this, and screen reader users hear the citation role applied to whatever is inside it.

src/prose/quote.css, adds 1,853 B gzip

A framework that cannot be measured cannot be trusted.

Ada Lovelace, On Shipping

The pull quote sets larger and drops the accent edge.

Show codeHide code

No quotation glyph is drawn with generated content anywhere in this file. VoiceOver and NVDA both announce ::before content in their default configurations, so a decorative opening quote becomes the word "quote" read before every pull quote on the page. The accent edge carries the same signal and is silent. The cite attribute on a blockquote is not surfaced by any browser: if the source matters, it has to be a real link in the attribution.

Worth knowing before you reach for bs-quote--pull: it sets its type step on the blockquote, and core sets the body step on every p at zero specificity, so the paragraph inside does not inherit the larger size. Put bs-text-h3 on the paragraph, as the example above does. The utility layer is last, so it wins with no specificity fight.