simon@letsbuild style-guide.html utf-8

Style guide

theme v3 — night terminal

The design system for LetsBuild.cloud. Dark only — a terminal doesn't have a light mode, and neither does this site. Built on the Dracula palette: a blue-violet ground with six bright inks, in place of v2's muted Rosé Pine and v1's saturated gruvbox primaries. Identity comes from structure: a statusline, an inverted title row, numbered section rules, a tree TOC. One shared stylesheet (/theme.css), and no scripts.

Principles

  1. The interface is a terminal application. The chrome is a statusline; the title is a selected row; the index is a dated listing; panels are bordered frames with tab labels. Structure does the theming.
  2. Dark, always. One mode, tuned properly, instead of two compromises. color-scheme: dark keeps native controls and scrollbars in-theme.
  3. Bright inks on deep ground. Six hues, each with a defined job — and all of them clearing AA on the ground colour, so the decorative tier is a rule, not a rescue. Body text is never coloured.
  4. Prose first. Monospace everywhere, but with generous leading, a real measure, and decoration that stays out of the reading column.
  5. Zero external requests. A script has to earn its place. System font stacks, CSS-only components, one shared same-origin stylesheet (/theme.css). A script never gets behaviour a native element or a CSS pattern can carry, and nothing on the site needs one.

Colour

Dracula: three stacked surfaces, three text tones, six hues. Surface is Dracula's own chrome tone and sits darker than the ground — as it does in Dracula's own UI — so the stack is a layering order, not a lightness ramp. Contrast ratios are measured against --color-bg; tones marked sit below 4.5:1 and are decorative-only — borders, fills, and large glyphs, never body-size text.

Surfaces

--color-bg #282A36 · page ground (Background)
--color-surface #21222C · cards, code, callouts — inset panels
--color-overlay #44475A · statusline, keycaps, tooltips, selection (Current Line)
--color-border #44475A · hairlines (Current Line again)
--color-border-strong #6272A4 · emphasised edges (Comment)

Text & interactive

TokenInkContrastRole
--color-text #F8F8F2 13.4:1 Body copy, headings
--color-subtle #B4BCD6 7.5:1 Descriptions, labels, quotes, secondary text
--color-muted #6272A4 3.0:1 Tree branches, separators, placeholders — never sentences
--color-accent #8BE9FD 10.3:1 Links, focus rings, primary actions (= foam)

Scroll the table sideways for the rest of the columns.

Hues

TokenInkContrastAssigned to
--c-love #FF5555 4.5:1 Danger buttons, warnings, "don't" panels
--c-gold #F1FA8C 12.7:1 Section numbers, dates, tips, badges, strings
--c-rose #FF79C6 6.0:1 Title-block fill, link hover, emphasis
--c-pine #50FA7B 10.4:1 Decorative rules and fills only — clears AA, but stays off text by rule
--c-foam #8BE9FD 10.3:1 Links, primary buttons, notes, functions in code
--c-iris #BD93F9 5.9:1 h3 sidebar, form controls, keywords in code

Scroll the table sideways for the rest of the columns.

Usage rules

Typography

One family — monospace, everywhere. System-installed developer fonts, no downloads, so the site renders in the reader's own terminal font.

--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", "SF Mono",
             Menlo, Consolas, "DejaVu Sans Mono", "Liberation Mono", monospace;

Scale

Seven steps from a 16px base. Monospace runs wide, so the scale is tight.

--text-3xl · 2.25rem · h1Title row
--text-2xl · 1.75remSection number
--text-xl · 1.4remDisplay
--text-lg · 1.2rem · h2/h3Section heading
--text-base · 1rem · bodyBody copy at a comfortable reading size.
--text-sm · 0.9remButtons, code, tables, tree listings.
--text-xs · 0.8remLabels, statusline, captions, tags.

Weight, leading, tracking

TokenValueUse
--weight-normal400Body copy
--weight-medium600Buttons, active tabs, post titles
--weight-bold700Headings, statusline segments, badges
--leading-normal1.7Body — generous, because monospace is dense
--leading-tight1.25Headings, tooltips, book metadata
--tracking-tight-0.01emThe h1 title row
--tracking-wide0.08emAll small-caps labels (h2, h4, tags, table headers)

Scroll the table sideways for the rest of the columns.

Hierarchy is typographic: the h1 is an inverted block, h2s are numbered small-caps with a rule filling the line, h3s carry an iris sidebar, h4s are small-caps labels. Uppercase + --tracking-wide is the signature for anything label-like.

When a caption wants that look but isn't a section, use .label on a paragraph instead of reaching for an h4. Heading levels are the document outline. A repeated card caption padding it out — ten alternating do and don't in the section below — is noise a screen reader has to walk past to find the real structure. Levels also have to descend one at a time: an h2 followed by an h4 leaves a hole in the outline.

Spacing & layout

Spacing scale

Six steps. Pick from the scale; never invent an in-between value.

--space-1 · 0.25rem
--space-2 · 0.5rem
--space-3 · 1rem
--space-4 · 1.5rem
--space-5 · 2.5rem
--space-6 · 4rem

Measure

The reading column is --layout-width: 760px — about 76 monospace characters. One column and no sidebars, unless a page opts into wide mode below. Horizontal padding --layout-pad-x: 1.5rem, vertical --layout-pad-y: 2.5rem. Wide content (code, tables) scrolls inside its own container; the page never scrolls sideways.

← --layout-width: 760px — every line of prose lives between these dashed edges. Full-bleed elements (statusline, panels) may escape, but their text stays on the measure.

Wide mode

Not every page is prose. A quick reference gets skimmed: you arrive looking for one thing, find it, and leave. Stacked in a 760px column, fifteen short sections turn into several screens of scroll with half the window unused.

.wide on the <body> moves the page cap to --layout-width-wide: 1272px, but only once the window is wide enough for two panes — under that the page stays on the measure, because a single 1175px pane of prose is worse than the 760px it started from. Nothing else changes — same statusline, same title row, same prompt — so a wide page still reads as the same site. Two rules come with it.

Prose goes back on the measure. A paragraph at 1200px runs past 120 characters and stops being readable. .measure caps a single block at --layout-width: the intro, an aside, anything meant to be read in sentences.

Sections tile. .panes lays the page's own sections side by side. Each pane is a <section>, and its h2 already draws the top edge — gold number, small caps, a rule filling the line. A row of those reads as a tiled terminal, and it takes no new border, glyph or hue to say so.

.measure — prose back at 760px
01 section
02 section
03 section
04 section

The track floor is 36rem, and it sits that high on purpose. Much under that a pane is too narrow to hold a code block at all, so auto-fit drops back to one column instead of squeezing. Panes align to start, so a short section keeps its own height rather than stretching to match a tall neighbour; the ragged bottom edge above is what that trade looks like.

Panes read in DOM order, left to right and then down, which is the order the h2 counter numbers them in. Multi-column (columns) would flow down one column and back up to the top of the next — right for an index, wrong for fifteen numbered sections. The quick reference uses both, and that's the split: columns for its table of contents, .panes for its body.

Wide mode belongs to static/. Posts render through template.html, which owns the <body> tag and is prose by definition. The Rust quick reference is the live example.

Components

The full set. Anything interactive here is styled native HTML, and every component is CSS all the way down — nothing to hydrate, nothing to script.

Statusline

The site's chrome, top and bottom of every page: solid segments in the vim mould. Foam segment for identity, overlay segments for context, surface fill between. Segments can be links. Every page header carries the same pattern: identity links home, and the current page's filename sits in a surface segment after the fill.

The bar wraps rather than clips. Segments are nowrap, so a bar with more content than room used to hide its tail behind overflow: hidden — the page width stayed correct and the last links were unreachable. Segments now drop to a second row instead.

simon@letsbuild ~/blog/2026-07-01-static-sites markdown 7 min read

The nav row

When a bar carries links as well as status, give it a second line. Add .statusline-stack to the bar and wrap each line in .statusline-row; the row draws the surface tone itself, so a stacked bar needs no trailing .fill. Both the header and the footer are built this way — status above, links below.

The second row is the command line. .statusline-nav drops the fill tone, the foam block and the segment chips, leaving a foam : prompt and plain subtle links that go foam and underline on hover. Two rows of solid segments read as equals, and the row naming the page should win. It also drops the segment padding and spaces itself with a gap, so the : lands on the page's left rail — the same column as the prompt, the h1 block and every line of prose.

The real thing is a <nav> with an aria-label: a page with two of them needs to say which is which, so the header's is Primary and the footer's is Footer. The specimens below are plain divs — a landmark that exists to be looked at is one more thing for a screen reader to walk past.

simon@letsbuild markdown 7 min read

Segments that don't fit

A link gets its own row before it gets truncated. The footer colophon used to wear a modifier that truncated it to fit alongside the links: at 1280px it showed 308 of its 449 pixels. You can't tell where a link goes when its name is cut off. It now sits on the status row with the links below it, and .seg-note lets it wrap inside its own box instead: prose in a bar of nowrap segments, read in full at 320px.

.seg-shrink is what's left for the case that genuinely can't wrap: a one-token label like a filename. It keeps its natural width until the row is tight, then yields to an ellipsis, and it's hidden outright below 640px where a label truncated to a sliver is only noise. Never put it on a link or anything else focusable.

A truncating segment must carry a title holding the same text. The ellipsis is a CSS effect, so the string stays whole in the accessibility tree and a screen reader reads all of it. The person who loses information is the sighted mouse user, and on a page with no JavaScript title is the only hover affordance there is. cursor: help signals it. title never reaches a keyboard or touch user, though, which is why the rule is to keep the modifier off anything clickable rather than to paper over one with a tooltip.

Prompt

The interactive signature: foam , subtle text, blinking block cursor. Used once per page at most — under the title. The blink respects prefers-reduced-motion.

cat 2026-07-01-static-sites.md

Title row

Page titles render as a selected row — rose fill, ground-coloured text, hugging its content width. Nothing else on the site is inverted at this scale, which is what makes it the title.

Building a static site generator

Buttons

Tags, badges & keycaps

draft go aws meta deprecated incident backup new v2.0 breaking Ctrl + R

Tags are outlined and quiet; badges are filled and loud — use badges sparingly. Keycaps get an overlay fill with a thick bottom edge.

Tags also work as links. Wrap a row of them in .tag-list (the topic row at the head of a post) or .tag-cloud (the tag index), both flex-wrap containers. A linked chip outlines in rose on hover, following the sitewide link rule. .tag-count puts a gold post count inside the chip — numbers are gold everywhere.

The chips don't have to be links. A row that only names things is a <ul>, one chip per <li>, with a .label above it and aria-labelledby pointing back at that label. Match the element to the job: linked chips are navigation and take a <nav>; unlinked chips are a list and take a list. Ten names inside a bare <div> reach a screen reader as one unbroken run of text, because a span is not an object the way a link is.

Reading

Cards

A surface panel with its label breaking the top border — the CSS equivalent of a ┌─ title ─┐ box. Accent variants recolour the tab and border together.

changelog

Watch mode now live-reloads the browser on save. Goodreads shelf data is cached for ten minutes.

now reading

The Mythical Man-Month
Frederick P. Brooks Jr.

Reading shelves

The homepage reading block: one card per Goodreads shelf, each tinted by a .shelf-gold, .shelf-foam or .shelf-iris modifier so the three groups read as three groups. The hue means which group here rather than its usual job — see hue carriers for the rule that allows it. The card title always says the same thing, so nothing rides on colour alone.

.book-glyph is a book drawn in CSS: a coloured spine down the left edge and two rules for the title on the cover, varying by position so a shelf isn't one book repeated. It replaced a hot-linked Goodreads cover that ran up to 750 KB for a 26px slot. It's decoration, so it's aria-hidden — the title and author sit beside it as text. .reading .card-grid aligns to start so a one-book shelf doesn't stretch to the height of a three-book one.

Callouts

note Neutral context the reader can skip without losing the thread.
tip A shortcut or sharper way to do the thing just described.
warning This step is destructive or easy to get wrong. Read before running.

Blockquote

Programs must be written for people to read, and only incidentally for machines to execute. Abelson & Sussman, SICP

Code

One code component, everywhere. The SSG's own tiny tokenizer (highlight.go — rust, go, python, shell, yaml, js; anything else stays plain) renders every block at build time, whether it's fenced in a post or sourced from a static page. They all come out as the same line-numbered pre.code rows: muted gutter, overlay row hover, surface fill, hairline border. An optional title bar names the file and language. Inline code gets the same treatment at 0.9em. Syntax inks, one job per hue: keywords iris; functions and types foam; strings, macros and flags gold; numbers and lifetimes rose; comments subtle italic — never muted, which fails AA at code size.

main.gogo
1// Render a post through the single template.
2func render(post Post) string {
3 html := markdown.ToHTML(post.Body, 0)
4 return strings.Replace(tmpl, "{{content}}", html, 1)
5}

Tables

CommandEffect
./ssgGenerate the site into ./build
go test ./...Run the test suite

Rows highlight on hover with a surface fill; headers are small-caps labels.

A table too wide for a phone goes in a .scroll-x wrapper, which scrolls it sideways instead of widening the page. A wrapper that actually scrolls also needs tabindex="0", role="region" and a name, unless focusable content spans its scrollable width. No browser makes a bare overflow box focusable, so without those a keyboard user reaches the first column and nothing else. Links don't count unless they span the scrollable width — a row of links in a frozen first column still leaves the columns to its right out of reach.

Give it a .scroll-hint too. A scroller can end flush with a column boundary and read as a finished table, with the rest of it hidden and nothing on screen to say so.

Two rules come with it. The width where a table stops fitting is a fact about that table, so theme.css hides the hint and the page reveals it — work out your table's width and put the one media query beside it. Work it out rather than measure it where you can: a measured breakpoint holds only for the font it was measured in, and this stack starts with three fonts a visitor has to install. And word the hint so it's true at every width it shows at: naming the columns that are off to the right is tempting and wrong, because which ones those are changes as the viewport does, and without JavaScript the page ends up telling you to scroll for a column that has been on screen the whole time.

Forms

Inputs sit on the surface colour with strong borders; focus swaps the border to foam and doubles it with a ring. Checks, radios and ranges use accent-color: var(--c-iris) — native controls, themed without extra markup.

Breadcrumbs & pagination

Tabs

The active tab joins the page: bordered on three sides, ground-filled, sitting on top of the rail. CSS only — each tab is a link to its page.

Progress

Segmented fills — terminal block characters, drawn with a repeating gradient.

Tooltip

The generator supports syntax highlighting in code blocks.

Pure CSS: content lives in data-tip, shown on hover and on keyboard focus. For glossary-grade asides only — anything important belongs in the prose.

The bubble wraps at min(32ch, 60vw) and is hidden with display, not opacity alone. An invisible box still occupies the scrollable overflow, so a tooltip near the right edge used to widen the whole page on a phone; transition-behavior: allow-discrete keeps the fade regardless.

Disclosure

Why so little JavaScript?

Every behaviour on the site — theme, tooltips, disclosure, navigation — has a native or CSS-only equivalent, and uses it. There is no script to break and none to audit.

Post index

The homepage lists posts by date: gold ISO dates, foam titles, subtle one-line descriptions, dashed hairlines between entries. No glyph prefixes — the date column and separators carry the structure.

Fixture list

The post index's counterpart for events that haven't happened yet — a race calendar, a season, a run of fixtures. A row gives the date, the event, and one subtle line holding the place and why it matters. The date column is the subtle tone, not gold: the list already carries six chip colours, and gold dates on top of them would be noise. Gold stays the date signal on the post index, where it is the only colour in the column.

A row is plain — no rail, no hue on the row itself. The sport rides in the chip beside the event name, and the chip is the one place a hue reaches text: it takes its sport's hue as outline and label, and .fixture-what .tag is styled for exactly that. When the run crosses months, split it into one list per month under an h2 naming the month, wrapped with its h2 in a .fixture-month. Above the lists, a .filters row of the same chips filters the page by sport: the page's script toggles aria-pressed, hides the other rows, and hides any month whose rows are all gone. Each row carries its last date as an ISO value in a data-end attribute, and the same script dims a row once that date has passed — the Hungarian Grand Prix row above — so the calendar reads as what's left, not everything that was. Below 640px the date column folds away and each row stacks; the fixed column is wider than a phone screen.

Hue carriers

.hue-gold, .hue-foam, .hue-iris, .hue-rose, .hue-love and .hue-pine set --hue on a container. They mark a group. They don't give the hue a second job. Two rules keep that true. The hue may only repeat a label the component already prints — the shelf title, the sport a row belongs to — so nothing is legible by colour alone. And on a carrier it may only reach a border, a rail or a fill, never text, which is what lets pine into the set at all. The one exception is the chip on a fixture row, where the hue colours the chip's own outline and label: the sport's name sits next to it, so the colour never carries the meaning. The reading shelves use the same variable.

Footer

The statusline mirrored at the page end, stacked the same way: the colophon on the status row, the way back on the nav row below it. Every footer links home and ↑ top. Where the header nav points at the content — posts, tags, about — the footer nav points at the way out: the feed, this guide, the top of the page.

Motion & accessibility

Voice & tone

The theme frames the words; style.md governs them: concise, approachable, informal but professional. Active voice, no weasel words, no clichés. Short paragraphs, concrete examples, code where code explains better than prose. The design should feel like the writing.

Token reference

The complete set — every value the theme uses, grouped by concern. The live copy is the :root block in /theme.css, the single stylesheet every page on the site (including this one) links. Change a token there and it changes everywhere at once.

Colour

TokenValueRole
--color-bg#282A36Page ground
--color-surface#21222CCards, code, callouts, row hover
--color-overlay#44475AStatusline, keycaps, tooltips, selection
--color-border#44475AHairlines
--color-border-strong#6272A4Emphasised edges, table header rule
--color-text#F8F8F2Body, headings
--color-subtle#B4BCD6Secondary text
--color-muted#6272A4Decorative text (branches, separators)
--color-accent#8BE9FDLinks, focus, primary actions
--c-love#FF5555Danger / warning
--c-gold#F1FA8CNumbers, dates, tips, strings
--c-rose#FF79C6Title fill, hover, emphasis
--c-pine#50FA7BDecorative fills
--c-foam#8BE9FD= accent
--c-iris#BD93F9h3 sidebar, form controls, keywords

Scroll the table sideways for the rest of the columns.

Typography

TokenValueRole
--font-monoJetBrains Mono → … → monospaceThe only family
--text-xs0.8remLabels, statusline, tags, captions
--text-sm0.9remButtons, code, tables, trees
--text-base1remBody
--text-lg1.2remh2 / h3
--text-xl1.4remDisplay
--text-2xl1.75remSection numbers
--text-3xl2.25remh1 title row
--leading-tight1.25Headings, tooltips
--leading-normal1.7Body
--tracking-tight-0.01emh1
--tracking-wide0.08emSmall-caps labels
--weight-normal400Body
--weight-medium600Buttons, active states
--weight-bold700Headings, segments, badges

Scroll the table sideways for the rest of the columns.

Spacing & layout

TokenValueRole
--space-1--space-60.25 / 0.5 / 1 / 1.5 / 2.5 / 4 remThe only permitted gaps
--layout-width760pxReading measure (~76ch)
--layout-width-wide1272pxPage cap under .wide — two panes
--layout-pad-x1.5remColumn side padding
--layout-pad-y2.5remPage top/bottom padding

Scroll the table sideways for the rest of the columns.

Borders & motion

TokenValueRole
--border-width1pxAll hairlines
--bar-width3pxAccent sidebars (h3, callouts, do/don't)
--radius0pxSquare corners, always
--transition0.15s easeColour/border transitions

Scroll the table sideways for the rest of the columns.

Do & don't

Do

Reach for a token. Every colour, size and gap in a rule is a var().

Don't

Hard-code a hex or a magic number — it silently forks the system.

Do

Build hierarchy from structure: inversion, numbering, rules, sidebars, small caps.

Don't

Prefix headings or list items with ornamental glyphs — that was v1's tell.

Do

Keep each hue on its one job, and reuse it when a new element matches that job.

Don't

Colour prose, add a second link colour, or use pine/muted for body-size text.

Do

Use native elements and CSS-only patterns for every behaviour they can carry.

Don't

Reach for a script where an element would do, or add webfonts, CDN assets, or any request that isn't the page.

Do

Let wide content scroll inside its own container.

Don't

Run prose past the measure, or let the page scroll sideways.

Do

Reach for .wide when a page is scanned rather than read, and tile its sections with .panes.

Don't

Widen a page of paragraphs, or leave prose at full width inside a wide one.