> about-this-design-how-i-redesigned-my-blog-like-a-hermes-terminal

about-this-design: How I redesigned my blog like a Hermes terminal
// ─────────────────────────────────────────────────────────────
OUTPUT 004 SEED 113913903 DATE 2026-06-28 CHARS 13,542 TAGS meta design css cloudflare-workers svg seo

Why

The old version of this blog was generic dark-blue with rounded cards. The design said nothing about who wrote it. Open Graph cards looked like every other developer blog. I wanted something that would survive being screenshotted in a Discord channel and still look intentional.

I looked at how Nous Research does its dark surface, and at hermesagent.ai for the brutalist side. Took the parts I liked: void-black base, one accent color, monospace everywhere, every block labeled.

1. The CSS Property System

Thirteen custom properties on :root, no CSS variables deeper in the cascade. The palette is a ladder of five surface colors and six accent tokens:

VariableValueRole
--void#0a0a0aPage background. Not pure black — just barely above, so borders at #000 are visible.
--void-1#0f0f12Card/container surface, one step above void
--void-2#15151aElevated surface (blockquotes, code blocks)
--fg#f7ede3Primary text. Warm off-white — avoids the clinical cold of pure white
--fg-2#d4cfc4Secondary text, link defaults
--muted#a0a0a0Tertiary: meta labels, timestamps, tag-line
--cyan#06b6d4Primary accent: links, code, selection, active states
--violet#8b5cf6Hover accent: link hover, keyword syntax highlighting
--green#22c55eStatus only: terminal prompt, success indicators
--red#ef4444Errors only: 404 indicators, validation failures
--amber#f59e0bData accent: number tokens in syntax highlighting
--steel#475569Borders, dividers, dashed rules
--steel-2#334155Darker border variant for hover states

The constraint: green and red are decorative-kryptonite. Every notification-color element on the page would start to look the same. They are reserved for their semantic meanings and never used decoratively.

2. Typography: Monospace-Only, Ligatures Disabled

The font stack is 'JetBrains Mono', 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace. Every element — titles, body, code, nav, footers — uses this stack. Zero bytes of network cost: JetBrains Mono ships with JetBrains IDEs and is installed on most developer machines; SF Mono ships with macOS; Menlo/Consolas are system fonts.

Ligatures are explicitly disabled: font-feature-settings: "calt" 0, "liga" 0. On a monospace blog, ligatures would break the terminal illusion — fi must occupy exactly two character widths, not one fused glyph. The base size is 14px at 1.65 line-height, chosen for comfortable reading at the 820px max-width container.

3. CRT Scanlines + Grid Overlay

Two fixed pseudo-elements sit above the page at z-index 999-1000 with pointer-events: none:

  • Scanlines (body::before): A repeating linear gradient of white at 2.5% opacity, 1px tall, 3px period. The first pass at 5% opacity was unreadable. At 2.5%, you only notice it when looking for it — the texture of something printed rather than pixel-rendered.
  • Grid (body::after): A 64px square cyan grid at 1.8% opacity. References old-school CAD and PCB layout tools where the background is a faint grid. Barely visible, but its absence would be felt.

4. Link Design: color-mix Underlines

Links use text-decoration-color: color-mix(in srgb, var(--cyan) 40%, transparent) — the underline is 40% opacity cyan, blending into the void background. On hover, the color shifts to violet and the underline goes to full opacity. The transition is 150ms on both color and text-decoration-color.

Focus-visible gets a 1px dashed cyan outline at 3px offset. Selection is cyan background on void text — inverted, not highlighted.

5. Terminal Navigation Bar

The nav bar is a green $ prompt, followed by a muted path (~/blog or the current page path), then bracket-wrapped links separated by pipe characters. The pattern:

$ ~/docs/sleepy/overview   [HOME] | [ARCHIVE] | [ABOUT] | [DOCS] | [RSS]

Each link is a bordered pill with transparent border that becomes steel on hover. The path changes per page — for posts it shows ~/post-slug, for docs it shows the doc path. This is the only element on the page that varies with navigation state; everything else is content.

6. Post List Design: Dashed Borders

Each post in the list is a <li> with border: 1px dashed var(--steel). No border-radius. No fill. On hover, the dashed border becomes solid cyan and the title color shifts to cyan. No background change, no shadow, no lift effect. The transition is 150ms on border-color. This gives posts the feel of archived records — labeled, retrievable, not 'designed'.

7. Meta-Row: OUTPUT + SEED + DATE + CHARS + TAGS

Each post page displays a meta-row of labeled values in uppercase small text:

  • OUTPUT — zero-padded publish order (001, 002, ...). Deterministic from the POSTS array index.
  • SEED — the first 4 bytes of a hash of slug|date, rendered as a uint32. Same post always produces the same SEED. Same hash across deploys.
  • DATE — the YYYY-MM-DD publish date from the post record.
  • CHARS — the post body character count, locale-formatted.
  • TAGS — comma-separated tag pills with dashed border treatment.

The labels are muted steel; the values are dimmer white. On mobile (<640px), the meta-row wraps with reduced letter-spacing to prevent three-row stacking.

8. Block-Level Element Styling

  • Blockquotes: 2px solid cyan left border, slightly elevated background (--void-2). No quote marks, no italic — the border does all the work.
  • Code blocks: &lt;pre&gt;&lt;code&gt; with --void-2 background, steel border, and a // LANGUAGE label generated from the class="language-X" attribute. The label floats at the top-right of the block.
  • Tables: Full-width, collapsed borders, header row with steel bottom border, alternating row backgrounds at --void-1/--void. No outer border — the grid lines define the structure.
  • Lists: Cyan bullet markers with 1.5rem indent. Dashed separator between list items in settings-style lists.

9. Syntax Highlighting: Regex, 4 Tokens

The highlighter runs at render time on the Cloudflare Worker — not in the browser. It processes &lt;pre&gt;&lt;code class="language-X"&gt; blocks with language-specific regex rules for ts, js, py, sh, json, and yaml. Four token types:

  • Keywords: violet. Matches const|let|var|function|return|if|else|import|export|class|async|await|...
  • Strings: cyan. Matches single-quoted, double-quoted, and template literals.
  • Comments: italic muted. Matches // and /* */ for JS/TS, # for Python/Shell.
  • Numbers: amber. Matches integer, float, and hex literals.

It is regex, not AST-based. Comments inside strings will false-positive. Acceptable for a blog where all code blocks are hand-written and short.

10. Open Graph Cards: SVG via Cloudflare Worker

Every page gets a unique og:image at /og/{slug}.svg. These are SVG files generated server-side by the same Worker. The design: void-black background, cyan terminal prompt, the post title in monospace, and a subtle grid overlay. No rasterization — the SVG renders natively in Twitter/Discord/Telegram link previews.

The Worker maps /og/*.svg to a template that renders the title text, wraps it to 40 characters, and positions it in a 1200x630 viewport. The font is embedded as a base64 data URI within the SVG, so it renders correctly even on clients without JetBrains Mono installed.

11. Structured Data: JSON-LD with BlogPosting Schema

Each post page embeds a &lt;script type="application/ld+json"&gt; block with a BlogPosting schema including headline, description (the post excerpt), datePublished, dateModified, author (Person with sameAs to GitHub), publisher, mainEntityOfPage, image (the OG card), inLanguage (en-US), keywords, articleSection, and wordCount (computed by stripping HTML tags and counting whitespace-delimited tokens).

The home page uses a WebSite schema with SearchAction potentialAction. This is the minimum viable structured data footprint for Google/Bing rich results without over-engineering.

12. RSS and Sitemap: Auto-Generated from POSTS

Both /rss.xml and /sitemap.xml are generated from the same POSTS array that drives the blog. No separate config. RSS includes full post content in content:encoded CDATA blocks, proper pubDate formatting (ISO noon UTC with per-post index-second offsets for deterministic sort order), Dublin Core creator tags, and Atom self-link. The sitemap includes all post URLs, all doc section URLs, and image sitemap extensions linking to the OG card SVGs.

13. The Build: Zero Client-Side JavaScript

One Cloudflare Worker at export default { async fetch(req) { ... } }. ~46 KB of JS source. No npm dependencies at runtime — everything is inlined. No JavaScript shipped to the browser: zero &lt;script&gt; tags in the HTML output. The page renders as static HTML with inline CSS. It is faster than the network round-trip to fetch its own social preview image.

The Worker handles: path routing, page rendering (pageWrap with parameterized title/description/extraHead), post list rendering, syntax highlighting, OG card generation, RSS/sitemap generation, and static asset serving (favicon).

14. Responsive Design: Two Breakpoints

One breakpoint at 640px for mobile: the meta-row switches to flex-wrap with reduced letter-spacing, the container padding drops from 2rem to 1rem, and the terminal nav stacks vertically. Below 400px, the nav brackets and pipes are hidden — just the links remain.

No breakpoint above 820px — the container is max-width 820px, centered. On ultra-wide screens, the void expands infinitely while the content column stays comfortable for reading. The scanlines and grid overlay fill the viewport regardless of content width.

15. Verification

Self-tested with Playwright headless at three viewports (390 / 820 / 1440). Screenshotted every page. Compared against what I had in my head. Fixed one issue caught this way: meta-row on mobile was forcing three rows instead of two — added flex-wrap and reduced letter-spacing at &lt;640px.

Tradeoffs I accepted

  • No light mode. Site becomes unreadable in bright sunlight. Acceptable for a personal build log.
  • Title H1 becomes a slug path. The H1 of each article is the kebab-cased title with &gt; prefix, e.g. &gt; building-sleepy-a-material-you-schedule-app-with-jetpack-compose. Looks like a cd command. The actual title shows as subtitle. Some will hate it.
  • Syntax highlighting is regex, not AST. Comments inside strings will false-positive. Acceptable for hand-written code blocks.
  • No cover images on posts. The OG card SVG serves double duty as both social preview and the de facto post header. Adding raster cover images would mean generating and storing them. The aesthetic gain does not justify the complexity.
  • Disabled transitions under prefers-reduced-motion. The 150ms hover transitions are the only motion on the page. They cost nothing to disable for users who have requested reduced motion.

How I worked

Two evenings. The first got the structural pieces in place: palette, font stack, layout, the nav prompt, the OUTPUT/SEED system. The second cleaned up the rough edges — the mobile wrap on the meta-row, the HTML entity escaping in pre blocks that the highlighter was mangling, the over-strong scanline opacity.

No tickets. No phases on a roadmap. No methodology slide. The only constraint that mattered: I can defend each decision in a sentence.

← cd .. /