Style guide
Everything on this page renders from the live stylesheet. If a token changes, this page changes with it — it cannot drift from the site the way a screenshot would.
Colour
Eight tokens, defined once in assets/css/tokens.css and redefined under
prefers-color-scheme: dark. No other file in the repo contains a colour literal.
Toggle your OS between light and dark to see both.
Every value below is asserted by test/tokens.test.mjs: ink tokens clear 4.5:1
against --bg, --ink clears 7:1, --accent clears 4.5:1, and --on-accent
clears 4.5:1 against --accent. A palette change that breaks legibility fails the
test rather than shipping.
--bgpage background--bg-sunkrecessed: code, pre--bg-liftraised: chips--inkbody text--ink-mutedmetadata, summaries--rulehairlines, borders--accentlinks, focus rings--on-accenttext on --accent--syn-keywordcode: keywords, operators--syn-stringcode: strings--syn-namecode: names, functions--syn-numbercode: numbers
Code is body-sized text, so the four syntax colours carry the same 4.5:1 obligation
as everything else. Three gruvbox syntax colours fail it and are substituted with the
nearest passing member of the same family — faded_green 4.3:1, faded_aqua 4.4:1,
and bright_red 4.3:1. Comments use --ink-muted rather than gruvbox gray, which
measures 4.0:1 and cannot legally carry text.
Type
Three roles. Body text uses no webfont, so reading never waits on a third party.
Eyebrow — JetBrains Mono, uppercase, tracked
Display — Newsreader
Second level
Third level
Body copy is the system sans stack. It should feel unremarkable, which is the point: the display face and the mono metadata do the work, and the reading column stays out of the way. The measure is capped at 34rem so a line never runs long enough to lose your place on the return sweep.
Elements
A link in running text, some inline code, and bold and italic.
A blockquote, for the times a quotation earns its own space.
// A fenced code block.
const tokens = readFileSync('assets/css/tokens.css', 'utf8');
| Column | Column |
|---|---|
| Table cell | Table cell |
| Table cell | Table cell |
Markdown
kramdown with GFM input — the GitHub Pages default. _config.yml sets no markdown:
key, so changing processor would mean changing that. Everything below was established
by building a probe post and reading the rendered HTML.
Footnotes
Footnotes work.1 They parse into correct role="doc-noteref" markup and an
endnote block at the foot of the page. They looked broken for a while only because
nothing styled them — the endnote list inherited body size and colour, so it read as
a stray numbered list rather than apparatus.2
The definition needs a colon. [^1]: text works; [^1] text does not, and fails
in the worst way — kramdown finds no definitions, so it leaves the markers and the
definition lines as literal text in the middle of the post. No error, no warning,
and it only shows up when you read the published page.
Task lists
- [x]renders a checked box- [ ]renders an empty one- the list bullet is suppressed, or each item shows a disc and a checkbox
Definition lists
A kramdown extension, not GFM.
- Term
- The definition, indented behind a rule.
- Another term
- Its definition.
Abbreviations
Also kramdown. The HTML spec defines this.
Code
Rouge tokenises fenced blocks and emits span classes; the four --syn-* tokens
colour them.
// a comment
const greeting = "hello";
function shout(text, times = 3) {
return Array.from({ length: times }, () => text.toUpperCase()).join(" ");
}
A second language, because Rouge emits different token classes per lexer and a class with no rule renders in body colour — highlighting that looks broken rather than absent. Exercising two lexers here means the test catches that.
class Kettle:
"""Boil water, eventually."""
def __init__(self, litres: float = 1.7):
self.litres = litres
self.on = False
def boil(self):
if self.litres > 0:
self.on = True
return f"boiling {self.litres}L"
Attribute lists
{: .lede} after a block applies one of this page’s classes to it — handy for
promoting a paragraph without writing HTML.
Not supported
| Syntax | What happens |
|---|---|
Bare URL https://example.com |
stays plain text — wrap in <> or use [text](url) |
Emoji :smile: |
passes through literally; needs the jemoji plugin |
Maths $$a^2$$ |
becomes MathJax \(a^2\), but no MathJax is loaded, so it shows as literal backslash-parens |
Favicon
16 · 32 · 64
A keyhole, or a chess pawn. The ambiguity is deliberate — escape rooms read the first, games read the second, and neither needs explaining.
assets/favicon.svg is the only file besides tokens.css that contains colour
literals, because an external SVG referenced from <link rel="icon"> cannot see the
page’s custom properties. test/tokens.test.mjs asserts the duplicated values match
the palette in both schemes, so the icon cannot drift.
The glyph fills 78% of the tile height, and that number is load-bearing. An earlier version used 56%. At 16px — the only size most people ever see — that left a head barely 3px across, and the shape collapsed into an unreadable blob. Clearance that looks composed at 64px is wasted resolution at favicon size. A test fails if the scale drops below 1.1.
Some clearance still earns its place: past roughly 90% the glyph looks cramped inside the rounded corners at the large sizes used for bookmarks and PWA icons. 78% is the compromise.
Jelly components
Loaded from jelly-ui.com and enhancement only. Each is shown twice: as it upgrades,
and with the upgrade suppressed so you can inspect the fallback without taking the CDN
down.
Note that a default <jelly-chip> paints its body on a <canvas> from
--jelly-color-background-neutral. Any Jelly token left unbridged in tokens.css
silently keeps Jelly’s own blue-grey default, so the bridge list is asserted by test.
Upgraded
Fallback, as it renders if package.js never loads