chapbook

v1.0.0 · MIT · no dependencies

Chapbook

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. Change the skin in section 02 and watch nothing structural move.

01

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.

grid: var(--rail) minmax(0, 1fr) · position: sticky · align-self: start

Rule 05

One row anatomy, everywhere

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.

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.

:root · @media (prefers-color-scheme: dark) · :root[data-theme="dark"]

Rule 07

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, break-inside: avoid on structural blocks, 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. Print is a real target here, not an afterthought — try it on this page.

@media print · --grain: 0 · href expanded on content links only

02

Tokens

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

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.

03

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
RoleFaceSizeDetail
Page titleDisplayclamp(2.5rem, 8vw, 4rem)lh 1.02 · ls −0.032em
Row titleDisplayclamp(1.15rem, 3vw, 1.45rem)lh 1.2 · ls −0.02em
IntroLanguage1.125rem--muted · max 32em
LedeLanguage1.1rem--muted · max --measure
BodyLanguage1.0625remlh 1.6
Row descriptionLanguage0.97rem--muted · lh 1.5
Section headMono 5000.7remls 0.13em · uppercase
Label / metaMono 4000.7remls 0.11em · uppercase · --faint
Rail numberMono 4000.7remtabular-nums · --accent
CodeMono 4000.8125remlh 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.

04

Components

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

You are looking at it. The number and the section name to the left of this text are sticky, and stay beside their content for as long as that content is on screen. On a narrow viewport they collapse into a single line above the content — resize the window and watch the grid change rather than the type shrink.

Not in v1

Margin notes are the obvious next component — they exist on Working Notes and are worth keeping — but they have shipped on one build rather than two, so they are a candidate for v1.1 rather than part of v1. The rule that keeps this system honest is the same one that keeps it small: two independent builds, or it is a preference rather than a rule.

05

Take it

There is no install step, no package to configure and nothing to initialise. Link one file and use the class names.

<link rel="stylesheet" href="https://style.aarontaylor.me/v1.0.0/chapbook.css">

Two version paths, and they are not the same promise. /v1.0.0/ 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://style.aarontaylor.me/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.

Or from npm

npm i chapbook

The minimum page

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>A page</title>
  <link rel="stylesheet" href="/chapbook.css">
</head>
<body>
  <header class="wrap bar">
    <a class="brand" href="/">site name</a>
  </header>

  <main class="wrap">
    <div class="hero">
      <p class="eyebrow">Label</p>
      <h1 class="name">The title</h1>
      <p class="intro">One sentence saying what this is.</p>
    </div>

    <section class="blk">
      <div class="rail">
        <p class="n">01</p>
        <h2>Section</h2>
      </div>
      <div class="body">
        <nav class="index">
          <a class="row" href="/somewhere">
            <span class="row-label">Status</span>
            <span class="row-title">The thing</span>
            <span class="row-mark" aria-hidden="true">→</span>
            <span class="row-desc">What it is.</span>
            <span class="row-meta">where it goes</span>
          </a>
        </nav>
      </div>
    </section>
  </main>

  <footer class="wrap">
    <div class="foot"><b>site name</b><span>A line.</span></div>
  </footer>
</body>
</html>

Working with an agent

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.

06

Version

The system carries the version, not the site. /v1.0.0/ 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.