Dark Mode Design — leftofnull.com
Dark Mode Design — leftofnull.com
Status: Approved Date: 2026-05-15 Author: Alexander Kahoun (with Claude)
Goal
Add a dark mode option to the Jekyll blog. On first visit, follow the operating system’s prefers-color-scheme. Provide a toggle in the navbar so visitors can override and persist their choice across sessions. Dark mode covers the full site — backgrounds, text, links, navbar, footer, post bodies, code blocks, category badges, and search input.
Non-goals
- Per-section theme overrides
- Live-following system theme changes after the user has explicitly toggled
- Animating the full-page color transition
- A separate “auto” position in the toggle (system preference is the default, not a third state)
- Automatic dark-mode treatment of images in posts (any per-post fix is a separate effort)
Architecture
A single source of truth — CSS custom properties — driven by a data-theme attribute on <html>. A tiny inline script in <head> sets the attribute before paint to prevent flash-of-wrong-theme (FOUC). A button in the navbar lets the user toggle, persisting their choice in localStorage.
1
2
3
<html data-theme="dark"> ← set by inline boot script + toggle
:root { --bg, --text, --surface, --link, ... } ← light tokens (default)
[data-theme="dark"] { --bg, --text, ... } ← dark overrides
Every existing hard-coded color in screen.css is refactored to reference a token. No new colors are introduced — existing rules just point at variables.
Token set
| Token | Light | Dark |
|---|---|---|
--bg |
#ffffff |
#0f1419 |
--surface |
#ffffff |
#1a2027 |
--surface-2 |
#fafafa |
#11161b |
--text |
rgba(0,0,0,.8) |
rgba(255,255,255,.87) |
--text-muted |
rgba(0,0,0,.44) |
rgba(255,255,255,.55) |
--text-faint |
rgba(0,0,0,.5) |
rgba(255,255,255,.45) |
--border |
rgba(0,0,0,.125) |
rgba(255,255,255,.12) |
--border-faint |
rgba(0,0,0,.05) |
rgba(255,255,255,.06) |
--link |
#2563EB |
#60A5FA |
--link-hover |
#1D4ED8 |
#93C5FD |
--code-bg |
#fff |
#11161b |
--code-border |
#E3EDF3 |
rgba(255,255,255,.08) |
--tag-bg |
rgba(0,0,0,.05) |
rgba(255,255,255,.08) |
--accent-green |
#1C9963 |
#10b981 |
The brand greens for follow/subscribe buttons keep their hue but are tuned for contrast on a dark background.
Components
_layouts/default.html
- Add a small inline
<script>in<head>(must be inline to avoid FOUC) that readslocalStorage.theme(falling back tomatchMedia('(prefers-color-scheme: dark)')) and sets<html data-theme="…">synchronously before stylesheets evaluate. - Add a toggle
<button>to the navbar<ul>as a new<li>immediately after thesearch-lunrinclude. Sun icon (fa-sun) shown in dark mode, moon icon (fa-moon) shown in light mode; the icon swap is driven by CSS attribute selectors so no JS is needed for icon state. - Replace the single
<meta name="theme-color" content="#2563EB">with two<meta>tags usingmedia="(prefers-color-scheme: light)"andmedia="(prefers-color-scheme: dark)"so mobile browser chrome matches the OS theme. (We don’t try to keep this in sync with explicit user toggles — it just follows the OS.)
assets/css/screen.css
- Prepend a
:root { … }block defining all light tokens. - Append a
[data-theme="dark"] { … }block with dark overrides. - Refactor existing rules: replace
#fff,rgba(0,0,0,.X),#2563EB, etc. withvar(--token)references. This is the bulk of the work — a careful but mechanical sweep.
assets/js/theme.js (new)
- ~25 lines.
- Attaches a click handler to the navbar toggle button.
- Reads current
data-themefrom<html>, computes inverse, callssetAttributeandlocalStorage.setItem. - Wraps
localStoragewrites in try/catch.
_sass/_syntax.scss
- Keep existing light Rouge token colors.
- Add a sibling block scoped under
[data-theme="dark"]that overrides Rouge token colors with a dark-readable palette (target: a Monokai- or base16-dark-style set). No additional compiled stylesheet needed — Jekyll compiles_syntax.scssintomain.css.
Data flow
Page load
- Browser parses
<head>. The inline boot script runs synchronously, before any body paints:1 2 3 4 5 6 7 8 9
(function () { try { var saved = localStorage.getItem('theme'); var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', theme); } catch (e) { document.documentElement.setAttribute('data-theme', 'light'); } })();
- Stylesheets load. Because
data-themeis already set, the correct token block applies on first paint. - Body renders. The toggle button’s icon reflects current
data-themevia CSS attribute selectors.
Toggle click
- User clicks the navbar button.
theme.jsreads currentdata-theme, computes the inverse, writes bothlocalStorageandsetAttribute.- CSS variable cascade re-evaluates immediately. No reload, no flash.
System preference change
- Not actively followed after a user has explicitly toggled. Once they express intent, their choice persists until they toggle again. (Simpler implementation and matches the dominant user mental model.)
Edge cases
localStorageunavailable — boot script and toggle wrap access in try/catch; toggle still works in-memory for the session.prefers-color-schemeunsupported —matchMedia(...).matchesreturnsfalse, default to light.- JS disabled — boot script doesn’t run; no
data-themeattribute;:rootlight tokens apply; toggle button is inert. Acceptable graceful degradation. - In-post images with white backgrounds — left as-is in v1. Per-post fix is
.dark-invertclass added by the author when needed. - Mixpanel — unaffected. Boot script runs before but does not interact with analytics.
- Featured image in
.fortags— already dark with white text; no change.
Testing plan
Static Jekyll site, so verification is manual visual review.
bundle exec jekyll serveand open in a browser.- Toggle light ↔ dark: click the toggle and confirm:
- Backgrounds flip; text remains readable (~4.5:1 contrast).
- Navbar, footer, category block, and search input all dark.
- Links visible; hover/active state distinct.
- Code blocks readable with dark Rouge palette.
- Tags, post meta, blockquote all legible.
- Persistence: refresh — theme persists. Open new tab — theme persists.
- System preference path: clear
localStorage, set OS to dark, hard reload — site loads dark. Set OS to light, hard reload — light. - No FOUC: hard-reload in dark mode while watching first paint — no white flash.
- JS disabled: disable JS in devtools, reload — site renders light, toggle hidden/inert, nothing visibly broken.
- Pages to verify: home (
/), a post, About (/about), a category archive, search results, 404.
Out of scope
See “Non-goals” above. Anything not listed in Components is not part of this work.