Vellum: a Hugo theme that sets every page as an engineering drawing
AI use
With assistance
Model: Claude Opus 5
- Issued
- Extent
- 8 min read · 1676 words
- By
- Felix Itzenplitz
- Subject
- hugo, vellum, theme, css, open-source, claude-code
- Also in
- Deutsch
Contents
I wanted to fix one annoying thing about my blog’s theme. Several weekends later I had written a new theme from scratch, given it a name, an MIT license and a generated changelog, and submitted it to the Hugo theme registry. At no point did I write an actual blog post. This is a well-documented failure mode and I walked into it with my eyes open.
It is called Vellum, this blog runs on it, it is now at 0.2.10, and it is listed at
themes.gohugo.io/themes/vellum
. It went up two patch versions while I was writing the first draft of this post, which tells you something about how the weekends went.
The premise
Blog themes mostly look like each other: a narrow centered column, a gray byline under the title, and a lot of white space doing work that a single rule could do better. That is fine for essays. It is wrong for what I actually publish, which is command blocks and terminal output.
So I took the reference from somewhere else entirely — the engineering drawing sheet. A drawn frame around the page. Graph paper inside it, and nowhere else. And instead of the gray byline, a ruled title block: the bordered box a real drawing carries in its corner to state what the sheet is, who drew it, when, and at what revision.
The one rule I gave myself was that the metaphor had to be load-bearing. If an element only looked like drawing furniture, it was decoration and it didn’t ship. That is a much stricter rule than it sounds, and it killed a lot of ideas that photographed beautifully.
Built with Claude Code and impeccable
Here is the part I should be upfront about: I did not hand-write most of this. I built it with Claude Code, and the thing that made it produce a coherent theme rather than a large pile of plausible CSS was a skill called impeccable.
impeccable’s trick is that it won’t let a design live only in the code. It keeps two documents beside the repo:
PRODUCT.md— what the theme is for, who it is for, and which of its properties are binding commitments as opposed to merely true today. URL stability and 4.5:1 contrast are binding. Working without JavaScript is not; it just happens to work, and I’m allowed to trade it away later without calling it a bug.DESIGN.md— the visual world as actually built, not as intended. It opens by conceding that where it and the stylesheet disagree, the stylesheet is right and the document is stale.
Then it holds the code against those documents as things change, and takes screenshots to check whether the thing it just described is the thing on the screen.
For a blog theme this is, admittedly, absurd overhead. It is also the reason the theme ended up with a real three-step line system instead of three tokens that all quietly resolve to 1px — which is precisely what it had at one point. Two of them were the same width, eleven declarations had drifted between them, and the result was pixel-identical either way. Nobody was ever going to catch that by looking at it. The document caught it.
This post was written the same way, which the theme now discloses by itself — that is the stamp in the title block at the top.
The zone rail, or: the part I’m unreasonably pleased with
On a real drawing, the border is divided into zones — letters down one edge, numbers along the other — so that two people on a phone call can agree about where they are looking. “The bracket in C4.” It is a coordinate system for a conversation.
Vellum keeps the letters and gives them a job: one zone per top-level heading, each one a link, each sitting at that heading’s own measured offset down the sheet, and lit in amber while you are reading it. It is a table of contents that costs no vertical space at all, because it lives in the frame margin — which was otherwise just ruling.

And if a page has fewer than two headings, the rail doesn’t render at all; the frame simply closes the column it would have taken. Lettering an empty margin would have been exactly the decoration I had banned in the first place.
Built for code, not for prose
The reading column is 92 characters wide. That is well past the 65–75 the typography books recommend, and it is on purpose: an 80-column terminal paste has to land in it without wrapping. I counted it off a rendered line instead of estimating, because every estimate I made turned out to be wrong by a wide margin.
Nothing casts a shadow. Depth is carried entirely by line weight — two widths crossed with three ink weights — and there is exactly one accent color, an amber that never does more than one job on a screen at a time. It’s the red pencil of the drawing office.

The index pages get the same treatment: posts separated by ruled lines rather than boxed in cards, because a drawing divides a schedule with rules.

What’s new in 0.2
0.2.0 was mostly plumbing — releases are scripted now, and CHANGELOG.md is generated from the commit history instead of being written by hand and immediately forgotten. It shipped two visible fixes: the zone rail no longer comes back as a little stack of marks in the frame’s corner on narrow screens, and the body text of an _index.md now attaches to the page head instead of sitting flush against the first post card, where it read as that card’s caption.
0.2.1 added the AI disclosure you can see on this post: a stamp in the title block, the same mark in the post index so you see it before opening anything, and an optional shortcode for marking individual passages rather than a whole post. Since I built the theme with an AI and write with one, having the theme state that in its own vocabulary seemed better than a disclaimer in the footer.
0.2.2 then turned those marks down. They had been drawn at the weight of a heading when what they annotate is body text, so they read as more important than the sentence they were attached to — which is the opposite of what a disclosure should do.
And what came after it
0.2.3 was the big one, and almost all of it was accessibility. The zone rail now sits after the sheet’s content in the DOM, because tabbing through a long post used to throw focus back to the top of the page halfway down. Disabled pagination text had been painted in a line token, at 2.06:1 against its ground. The AI mark on a post card is named in the field grid instead of existing only as a hover title, so it discloses to a keyboard and a touch reader too — a disclosure that only a mouse can find is not a disclosure. The same release gave search results the post card’s field grid and gave a query that matches nothing the 404’s exits rather than a grey line.
0.2.5 was a long list of small corrections, most of them things that were only wrong on somebody else’s site: icon paths that didn’t resolve against a baseURL with a subpath, latestPosts = 0 not actually hiding the list, a header logo sized from the source rather than the resized image. 0.2.6 stopped the JSON-LD block from being escaped into a JSON string, which had been quietly making it unparseable.
0.2.7 is the one I’d defend hardest. A theme cannot write its consumer’s config, and three settings it depends on produced a page that rendered without complaint and was wrong when they were missing: a search box with no index behind it, a publish root with no error document, and code blocks with a light palette baked into the markup that stayed light on a dark sheet. All three were documented in the README and enforced nowhere. So the theme now checks them at build time and prints the TOML to paste — as a warning, not an error, because an upgrade must not break a build that has been running half-configured for a year.
And then the registry told me one of those guards was wrong. themes.gohugo.io builds every submission against its own demo site, and that build printed my Chroma warning. The guard was right about the symptom and wrong about the remedy: a render hook can pass noClasses per call, so it never needed the site to configure anything. 0.2.10 moved the setting into _markup/render-codeblock.html and deleted the guard that asked for it. Being listed found a bug in the code I’d written to find bugs, which is about the most on-brand outcome available.
Try it
[module]
[[module.imports]]
path = "github.com/cebor/vellum"
Then hugo mod get -u. There’s a live demo at
pages.stkn.org/felix/vellum
, and the source, README and changelog are at
github.com/cebor/vellum
. It needs Hugo extended 0.158 or newer.
Note
The registry entry is at themes.gohugo.io/themes/vellum . When I first wrote this post it was still in the queue — upstream had paused merging new themes — so if you read the earlier version and gave up looking, it’s there now.
It was built for this blog first, and where my needs and a hypothetical adopter’s collide, my blog wins. It’s documented in full and the demo is real, though. If your posts are mostly code too, it might suit you.