A small CSS system for documents that want to read like documents. Monospace carries structure, a second face carries language, and separation comes from rules and space rather than from cards.
This page is the specimen. It is set in the system it documents, using the same stylesheet you are invited to take — so every component below is the real implementation rather than a picture of one. The theme toggle is real. So is the print stylesheet — press print and read the result, because paper is a target here rather than a courtesy. Change the skin in section 03 and watch nothing structural move.
01
What it is for
A chapbook was a distribution technology rather than a look — standard formats, shared stock, no design per title, cheap enough to give away. This system inherits that, including the parts that sound like limitations.
For
Documents that are read
Specifications, notes, changelogs, internal docs, a personal site that is mostly writing.
Anything that leaves the screen
Paper, PDF, an archive, a printer in an office. Rule 09 is load-bearing, not a courtesy.
A settled default
For a person or an agent, so that no time at all is spent deciding how it should look.
Being taken and edited
One file, no build, no configuration, MIT. Editing it is the configuration.
Not for
Applications
No form controls, no grid utilities, no cards. A dashboard wants a different tool, not this one with additions.
Looking unique
An explicit anti-goal. Two sites built on this should look related; if that is a problem, the problem is the choice of system.
Teams wanting configuration
There are no options and there is no theme API. The file is the API.
Being a brand
It is stock, in the printer’s sense — shared inventory rather than identity.
Chapbook is not a way to make your documents look distinctive. It is a way to stop having the conversation, so the writing can start — and it holds up when the page leaves the screen.
02
The rules
Nine rules, each already followed by two independently built sites before it was written down here. A rule that has survived one build is a preference; these survived two, with different palettes and different faces.
Rule 01
Two faces, and the mono is the constant
One monospace sets every label, number, address, breadcrumb, button and footer line — anything that tells you what kind of thing you are looking at rather than saying something. A second face carries the language, and it is the site’s own. If a piece of text tells you what kind of thing you are looking at, it is mono, uppercase and tracked. That single test decides every case.
--mono is shared across sites · --language is not · 0.68–0.75rem · 0.08–0.16em tracking
Rule 02
No cards
No radius, no fill, no shadow to say “separate object”. Objects are separated by a hairline and by space. The only fill in the system is the row hover at about 1.07:1 — felt rather than seen. If you can identify its colour, it is too strong. The single exception is a code field, which earns its border by genuinely being a different surface.
border-radius: 0 everywhere · one fill, --sunk · zero box-shadow
Rule 03
Two weights of rule, and they mean different things
A 1px hairline separates peers. A 2px ink rule opens and closes the document: under the masthead, above the first block, above the footer. Those three lines are what hold a page together at a glance. Do not add a third weight — the moment there are three, none of them mean anything.
1px var(--rule) between peers · 2px var(--ink) at the document edges
Rule 04
The numbered rail
A sticky left column carrying a number and a mono section name, content on the right. It stays beside its content for as long as that content is on screen, and collapses to a line above the content on narrow viewports. align-self: start is required, or there is nothing to stick within — a grid item stretches to the row height by default, and a stretched item cannot be sticky. Remove that one line and the rail silently stops moving, with no error anywhere.
Mono label left, title in the display face, mark right, then an optional description and a mono line carrying the destination. The whole row is the link and the mark is decorative. On hover the hairline redraws in ink from the left and the mark nudges up and right. Use ↗ for a destination that leaves the site and → for one that does not, so the mark keeps meaning something. The rule governs this row — the one that navigates. A repeated block that does not link is a different object and does not owe it an anatomy.
rules bleed 0.75rem past the text so the hover fill has somewhere to sit
Rule 06
Tokens declared three times
Bare :root is the light palette and the fallback. The media query is guarded with :not([data-theme="light"]) so an explicit choice beats the system preference. [data-theme="dark"] restates it so the toggle wins in both directions. Never style a component from inside one of those blocks — a rule written there exists in one theme and not the other, and you will not notice for weeks.
Contrast is measured, and the measurement is written down
Every text token carries its ratio, measured against the worst-case surface rather than the flat background — the grain overlay sits between the text and the page and moves the surface toward the text in both themes. Ink is never pure black on pure white; at 21:1 that pairing glares on long text. In this repo the measuring is done by the build, and a token below AA fails it.
WCAG AA 4.5:1 for text · hairlines and fills exempt · measured, not estimated
Rule 08
A bordered control, not another word in a row of words
The theme toggle is a 2rem square with a hairline border, because it does something where the links beside it only go somewhere. It ships with the hidden attribute set and is revealed by script, so a visitor without JavaScript is never offered a button that cannot work.
2rem square · 1px border · hidden until bootstrapped
Rule 09
It prints
Ink on white, controls gone, every panel open, and link destinations expanded after the link text — a printed page has no hover and no address bar, so a bare “read more” prints as a dead end. The sheet is set rather than surrendered: @page owns the margin instead of the print dialogue, paragraphs carry orphans and widows, and a heading never ends a sheet with its section overleaf. Print is the target this system is actually for — try it on this page.
Eight colour tokens, and the names are the contract. Change every value you like; keep the names, so a diff between two sites built on this system shows only the differences that were intended.
Skin
aarontaylor.me — Monochrome, no accent. The hub is an index of other work, so it stays neutral and lets each project keep its own colour.
Colour tokens for the Neutral skin, light and dark, with contrast measured against the worst-case grain pixel
Token
Light
On surface
Dark
On surface
Role
--paper
#fcfcfc
—
#111
—
Page ground
--sunk
#f4f4f4
—
#191919
—
Row hover, code field
--rule
#e5e5e5
—
#2a2a2a
—
Hairlines
--ink
#111
17.32
#ededed
15.46
Body, titles, 2px rules
--muted
#444
8.93
#b4b4b4
8.73
Ledes, descriptions
--faint
#666
5.27
#8a8a8a
5.24
Mono labels, meta
--accent
#111
17.32
#ededed
15.46
Links, rail numbers
--accent-deep
#111
17.32
#ededed
15.46
Link hover
weindie.com — Warm paper, deep green, Newsreader. Shipped — these are the values running on the site today.
Colour tokens for the Green skin, light and dark, with contrast measured against the worst-case grain pixel
Token
Light
On surface
Dark
On surface
Role
--paper
#fbfaf7
—
#121412
—
Page ground
--sunk
#f2f0ea
—
#1a1d1a
—
Row hover, code field
--rule
#d9d7ce
—
#2d312c
—
Hairlines
--ink
#16181a
17.05
#ece9e1
15.26
Body, titles, 2px rules
--muted
#54574f
7.05
#a9a89d
7.73
Ledes, descriptions
--faint
#6b6e66
4.97
#83867c
4.99
Mono labels, meta
--accent
#27705a
5.66
#5cb894
7.70
Links, rail numbers
--accent-deep
#1a5342
8.53
#7fd0af
10.18
Link hover
notes.aarontaylor.me — Warmer paper, clay accent, Source Serif 4. Clay is the colour of a correction, and the site is a notebook of them.
Colour tokens for the Clay skin, light and dark, with contrast measured against the worst-case grain pixel
Token
Light
On surface
Dark
On surface
Role
--paper
#faf8f3
—
#14120f
—
Page ground
--sunk
#f1ede3
—
#1c1a16
—
Row hover, code field
--rule
#ddd7c9
—
#302c25
—
Hairlines
--ink
#191714
16.13
#ece7dc
14.64
Body, titles, 2px rules
--muted
#55514a
7.11
#a8a296
7.11
Ledes, descriptions
--faint
#6c675e
5.06
#8b8578
4.92
Mono labels, meta
--accent
#9a4f2b
5.37
#d98b62
6.72
Links, rail numbers
--accent-deep
#7d3e20
7.33
#eba57e
8.78
Link hover
Ratios are measured against the worst-case grain pixel, not against --paper. The overlay sits between the text and the page and moves the surface toward the text in both themes, so a ratio taken against the flat ground flatters itself. --rule and --sunk are exempt: they are edges and fills, and WCAG does not ask a hairline to be legible.
The build recomputes every number on this page from the stylesheet it ships, and exits non-zero if any text token drops below 4.5:1. A failing token cannot reach the site, and this table cannot go stale.
Layout tokens, reconciled across the three sites — they had drifted, and these are the settled values: --gut clamp(1.25rem, 5vw, 2.75rem) ·
--wrap 62rem ·
--rail 10rem ·
--measure 34em ·
--ease cubic-bezier(0.22, 0.61, 0.36, 1). Rem everywhere.
04
Type
Two faces do all the work. If a piece of text tells you what kind of thing you are looking at, it is mono, uppercase and tracked. If it says something, it is the language face. That test decides every case, which is why the system needs no third face.
The type scale
Role
Face
Size
Detail
Page title
Display
clamp(2.5rem, 8vw, 4rem)
lh 1.02 · ls −0.032em
Row title
Display
clamp(1.15rem, 3vw, 1.45rem)
lh 1.2 · ls −0.02em
Intro
Language
1.125rem
--muted · max 32em
Lede
Language
1.1rem
--muted · max --measure
Body
Language
1.0625rem
lh 1.6
Row description
Language
0.97rem
--muted · lh 1.5
Section head
Mono 500
0.7rem
ls 0.13em · uppercase
Label / meta
Mono 400
0.7rem
ls 0.11em · uppercase · --faint
Rail number
Mono 400
0.7rem
tabular-nums · --accent
Code
Mono 400
0.8125rem
lh 1.65 · --sunk field
Sizes live on classes, never on elements. A base h1 rule gets overridden on every page that uses it, and at that point it is not a base rule — it is a default nobody wants, written once and fought four times.
Three faces are declared: --mono, --language, and --display, which defaults to --language so most sites never think about it. Set it separately only when titles genuinely want a different face — the hub does, because Archivo Black sets its titles and Roboto sets its prose.
05
Components
This is a margin note.Mono, faint, and in the rail beside the thing it glosses — the same column the section number lives in.
Every component below is live. Hover a row to see Rule 05, press the toggle in the masthead to see Rule 06, and print the page to see Rule 09.
The index row
The code field
.row:hover::after { transform: scaleX(1); }
The one bordered, filled object in the system. It earns the exception in Rule 02 by genuinely being a different surface, rather than being a card drawn around ordinary content.
The rail, and the margin note
You are looking at both. The number and the section name are sticky and stay beside their content for as long as that content is on screen; the mono paragraph under them is a margin note, which is the same column carrying prose instead of metadata. Narrow the window — or the container, which is what the system actually measures now — and the whole rail collapses to a line above the content.
The sub-heading
That is one, and it is the only one. An h3 used to arrive at 19.89px against 17px body text at the same weight, which is a difference no reader sees. It is mono and tracked instead, matching the rail’s own heading, because a sub-heading names the part of the document you are in rather than saying something — which is Rule 01’s test. One treatment, not a scale.
Lists and quotations
A list item, with the marker in --faint because a bullet is punctuation rather than content.
The gap between items is smaller than the gap between blocks, so a list reads as one object.
A quotation is opened by the document weight — the same 2px in ink that opens the page under the masthead. No fill and no italic: the rule and the indent are the whole treatment.
The system, about itself
The table and the text control
Both are above, in section 03. The table is hairlines and one 2px head rule, with no zebra fill — the system has one fill and it is the row hover, so a striped table would be the second. The skin buttons are the text form of Rule 08: a border, never a fill, and the pressed state deepens the border to ink rather than filling the box.
06
Take it
There is no install step, no package to configure and nothing to initialise. Link one file and use the class names.
Two version paths, and they are not the same promise. /v1.2.2/ is exact: it never changes, and it is cached for a year. /v1/ follows the major line and picks up additive releases, cached for a day. Link the exact one unless you specifically want the updates.
Or vendor it, which is better
curl -O https://chapbook.page/chapbook.css
Copying the file into your own repo costs one request less, survives this domain disappearing, and lets you edit it — which you are meant to do. A system you cannot change is a dependency.
Point it at system.md, which is the whole system as plain text — the nine rules, the token contract, the markup for every component, and the rules for extending it without drift. llms.txt is the short version for a small context window. There is also a skill, in the format weindie.com uses, in skill/ in the repository.
07
Version
The system carries the version, not the site. /v1.2.2/ never changes, so a page that links it never breaks.
One rule decides a major bump: a token rename or a removed primitive. Those are the only two changes that can break a site downstream — everything else is additive, and additions ship as a minor version at the same URL.
What is deliberately not here: margin notes, tables as a styled component, form controls, and any kind of grid utility. Each is a real gap. None of them has been built twice yet, and the system stays small by refusing to describe anything it has not had to do.
The changelog is in the repository, and it records why a thing changed rather than only that it did.