This site serves the same page four ways. A browser gets HTML. curl gets ANSI-coloured text. ?format=md gets the Markdown source, ?format=json gets structured data. One URL, one build, four representations.

I built it in a session with Claude Code. This is what actually happened, including the parts that went badly, because the interesting content is in the failures rather than the result.

The design decision that mattered

The obvious approach is to render each format from the Markdown source. Zola already turns Markdown into HTML, so add a second renderer for ANSI and you are done.

That is wrong here, and it took a correction to see why. The body Markdown is not the page. title and description live in front matter and get assembled by the templates. Feed content/_index.md straight to a renderer and you get output starting mid-sentence, with no heading and no description. The template owns the composition, so anything that skips the template renders something the site never publishes.

So the built HTML is the single source, and every other format derives from it:

content/*.md ──(zola)──> public/**/index.html
                           ├─(pandoc)──(glow)──> index.ansi
                           └─(glow --style notty)──> index.txt
content/*.md ──(front matter stripped)────────────> index.md

Markdown is the deliberate exception. It is copied from source rather than converted back out of HTML, because the source is the real thing and a round-trip would be a lossy reconstruction of something already on disk.

This is the same trick style.ysap.sh uses, inverted. That site renders Markdown to ANSI first and derives its HTML from the ANSI, so the formats cannot disagree. It also means its HTML is terminal-shaped, which is the point there and not here. Same guarantee, opposite direction: one rendering pass, everything else a transform.

Colour, and why the palette is not mine to choose

curl style.ysap.sh looks better than this site does in a terminal. It is worth being precise about why.

That site emits 1254 absolute 256-colour codes and 20 background colours. The logo is 48;5;223 — a hardcoded cream background. It looks great on a dark terminal and it does not care what theme you use, because it brought its own. To replay that in a browser it ships 1118 lines of CSS mapping the 256-colour palette to classes.

The alternative is to use only the 16 base colours. Those are not colours. They are slots. \e[35m means “slot 5”, and the terminal decides what that is — which is what a theme mostly is. Nord renders slot 5 as #B48EAD, Rose Pine as #C4A7E7, Solarized Light as #d33682. Emit slot numbers, never set a background, and the page renders natively in whatever theme the reader already chose. On a light terminal it stays readable, without guessing.

The cost is real. Sixteen slots and no backgrounds cannot draw that logo. The site is plainer than it could be. In exchange it does not fight anyone’s colour scheme, and the choice belongs to the reader rather than to me.

The semantics are still mine — headings magenta, links cyan, URLs dimmed — but they are expressed as slots, so the mapping to actual pixels happens in the terminal, after I am done.

What went wrong

The rendering tool silently dropped every colour. glow v2 emits \e[;1m — bold, with an empty colour slot. Its own shipped styles do the same, so it is not a problem with our style. glamour v2 moved to lipgloss v2, where lipgloss.Color no longer resolves numeric strings like "5" or "252", and every numeric colour quietly becomes nothing. The fix was pinning glow to 1.x, capping Renovate below 2.0.0, and opening an issue so the pin has an exit rather than rotting.

Local verification tested the wrong binaries, three times. The local glow was a source build of unknown version and rendered correctly. The container pinned v2 and did not. Alpine ships pandoc 0.1.1.1, which left the page navigation in the ANSI output; the local pandoc was 3.10 and stripped it. Then CI tested a stale image, because the service pulled a mutable tag and the runner reused a cached copy. Three separate bugs, one cause: “verified locally” meant a different artifact than the one shipping. The fix is boring — pin every tool version in mise.toml and the image, and test a pod built from the real image.

The tests passed on visibly broken output. At one point the page rendered entirely in one grey. The suite went 15/15 green, because it asserted “has escape codes” and “only uses base-16” — both true of garbage. Tests that check the shape of the output rather than whether it is right will confirm whatever you give them. Three assertions fixed it: no empty colour slots, headings styled distinctly from body, more than one colour present.

The linter caught a bug in the tests. shellcheck flagged SC2314: in bats, ! does not fail a test unless it is the last command. Four negative assertions had been passing unconditionally. Then the fix repeated the mistake — run ! head file | grep applies to head, not the pipeline.

One CSS-shaped detail took three attempts. ?format=md returned application/octet-stream, because nginx try_files serves a file without re-running location matching, and .md is not in mime.types. default_type text/plain fixed it, and deleting three now-dead location blocks came with it. Later a types block gave each representation an honest content type — text/markdown, text/plain+ansi — after checking that a types block merges with mime.types rather than replacing it and quietly breaking CSS.

On working with an agent

The agent was fast at the mechanical work: scaffolding the chart, wiring nginx, writing the bats suite, reading upstream source to find the lipgloss regression. That part is real and it is not a small saving.

It was also confidently wrong on a regular basis. It diagnosed a Kafka crashloop as a read-only filesystem problem when an identical StatefulSet ran fine on the other cluster. It blamed TERM for the missing colours and was wrong twice before finding the version mismatch. It told me to delete a PVC to wipe corrupt data on a storage class that provisions by name and retains on delete, so the delete was a no-op and the corruption came straight back — a fact documented in a comment in my own repo that it had read hours earlier.

The pattern is consistent: it stops at the first plausible cause, and the first plausible cause is often wrong. What broke that pattern was comparing against something known-good — the other cluster, the other glow version, the same style on both binaries. Not more reasoning. A reference point.

The useful division was: it does the work and holds the context, I decide what is true. Every real correction in this session came from pushing back — the source-of-truth inversion, the terminal-theme objection, running the thing in a real pod instead of trusting a local run. The agent implemented all of those well. It proposed none of them.

It also wrote a first version of this page full of the phrases you would expect. That needed removing too.

Where it landed

Four formats, one build, no backend. The negotiation is a map on the user agent, with ?format= as a deterministic override, because sniffing is a heuristic that breaks on bots, HTTPie, and curl | less. Every file stays reachable directly. The container runs nginx unprivileged on a read-only root filesystem. Eighteen tests run against the built image in CI.

The plain version is one query parameter away, for anyone whose terminal disagrees with all of this.