Intro
Until now, this blog was always dark. The hydrogen-hugo theme ships one palette: a #17212B background, light gray text, and a mint green accent. I wanted a light version too. By default it follows the device setting, and a manual toggle serves anyone who prefers otherwise.
The theme sets every color through Sass variables, and Sass resolves them at build time. A runtime toggle needs colors that can change in the browser, so the work was mostly converting Sass colors to CSS custom properties. It went smoothly until someone opened the contact page in light mode and typed into the form: the text was invisible.
TL;DR: Define the colors as CSS custom properties under
[data-theme="dark"]and[data-theme="light"]. Setdata-themeon<html>with a tiny inline script before the first paint. Replace the theme’s Sass color references withvar(--c-*). Then check every rule you did not write yourself. Bootstrap compiles.form-control { background-color: $input-bg }to a literal#17212b, and no token can override that. One line,background-color: var(--c-bg), fixed the form.
The Figure
The sheet on the projector is the page, and the two gels are the two palettes. Key 1 (or the middle of the figure) turns the knob through Auto, Light and Dark. Key 2 (or the left side) flips the light sensor. The sensor stands for the device’s prefers-color-scheme and only matters in Auto. Key 3 (or the right side) applies the fix. The code block is painted dark on the sheet on purpose, so it stays dark under either gel. The form field was painted dark by accident. That is the bug in Issue 1: under the light gel, its text drops to 1:1. The readout gives the field’s contrast for the current state. I generated the figure with the anatomy agent skill.
Prerequisites
- Hugo 0.168.0 with Dart Sass 1.105.1, building the theme SCSS through
toCSSwith thedartsasstranspiler. hydrogen-hugotheme, vendored into the repo, on Bootstrap 5.2 SCSS.- Theme colors in the theme’s
config.toml, injected into Sass as variables:
color_primary = "#61eebc"
body_color = "#17212B"
text_color = "#cbcbcb"
- Hosted on Cloudflare Workers static assets (see the previous post), so everything must work as plain static files.
Setup
Step 1: Color tokens
I added one new file, assets/scss/_theme.scss, imported right after Bootstrap. It defines a token for every role a color plays, not for every color:
:root,
[data-theme="dark"] {
color-scheme: dark;
--c-bg: #{$body-bg};
--c-fg: #{$text-color};
--c-fg-strong: #{$white};
--c-fg-strong-10: #{rgba($white, .1)};
--c-border: #{$border-color};
--c-primary: #{$color-primary};
--c-input-focus: #{lighten($body-bg, 5)};
--c-inverse-bg: #{$white};
--c-inverse-fg: #{$black};
// ...
}
[data-theme="light"] {
color-scheme: light;
--c-bg: #f8fafc;
--c-fg: #3c4856;
--c-fg-strong: #17212b;
--c-fg-strong-10: rgba(23, 33, 43, .08);
--c-border: #d5dce4;
--c-primary: #0a7a52;
--c-input-focus: #ffffff;
--c-inverse-bg: #17212b;
--c-inverse-fg: #ffffff;
// Bootstrap's own variables
--bs-body-bg: #f8fafc;
--bs-body-color: #3c4856;
--bs-border-color: #d5dce4;
// ...
}
I interpolated the dark values from the original Sass variables, including the lighten() and desaturate() derivations that the theme already used. That way the dark theme compiles to exactly the colors it had before, and I only had to design the light side.
Then I replaced the color references in the theme’s SCSS (_common, _typography, _buttons, templates/_main, templates/_navigation), 67 in total. Most were mechanical:
// before
.nav-link:hover {
color: $white;
background-color: rgba($white, .1);
}
// after
.nav-link:hover {
color: var(--c-fg-strong);
background-color: var(--c-fg-strong-10);
}
In the dark theme, $white means “the strongest foreground color”, not literally white. I named the token --c-fg-strong instead of --c-white. Then it can become #17212b in light mode, and the code does not lie about what it does.
Step 2: Pick light colors that pass WCAG AA
The mint accent #61eebc is fine on the dark background but reaches only 1.39:1 on a light one. The light palette uses a darker green instead. I checked each text and background pair that uses the tokens against the 4.5:1 minimum:
| Pair (light theme) | Colors | Contrast |
|---|---|---|
| Body text on background | #3c4856 on #f8fafc | 8.91:1 |
| Headings on background | #17212b on #f8fafc | 15.57:1 |
| Links and accent on background | #0a7a52 on #f8fafc | 5.12:1 |
| Accent on card surface | #0a7a52 on #eef2f6 | 4.76:1 |
| Inline code | #b3155f on #f8fafc | 6.29:1 |
| Old accent, for comparison | #61eebc on #f8fafc | 1.39:1 |
Step 3: Set the theme before the first paint
The main script is deferred. If it applies the theme, a visitor on a light device first sees a dark page flash. The fix is a small inline script at the top of <head>, before the stylesheet:
<meta name="color-scheme" content="dark light">
<script>
(function () {
var s = "auto";
try { s = localStorage.getItem("theme") || "auto"; } catch (e) {}
if (s !== "light" && s !== "dark") s = "auto";
var d = document.documentElement;
d.dataset.themeSetting = s;
d.dataset.theme = s === "auto" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : s;
})();
</script>
It sets two attributes on <html>. data-theme is the resolved theme (light or dark), and the CSS keys on it. data-theme-setting is what the visitor chose (auto, light, or dark), and the toggle uses it to pick its icon. The try is there because localStorage throws in some private browsing modes. The page then follows the device.
Step 4: The toggle
The button cycles Auto, Light, Dark. It holds three inline SVG icons (half circle, sun, moon) and CSS shows only the one that matches data-theme-setting:
.theme-toggle > span { display: none; }
[data-theme-setting="auto"] .theme-toggle .icon-auto,
[data-theme-setting="light"] .theme-toggle .icon-light,
[data-theme-setting="dark"] .theme-toggle .icon-dark {
display: inline-block;
}
The script stores light or dark in localStorage and removes the key for Auto, so Auto is the default and needs no storage. It also listens for device changes. That is what makes Auto follow a phone that switches to dark mode in the evening:
mq.addEventListener("change", function () {
if (d.dataset.themeSetting === "auto") apply("auto");
});
Step 5: Things that stay the same in both themes
- Code blocks stay dark. The syntax colors come from Chroma’s
onedarkstyle, which I tuned earlier for contrast on a dark background. A re-tune for light mode doubles the work for no real gain. So I define--c-code-bgonce on:rootand pin the plain code text to#abb2bfin light mode. - Category colors. Category labels get an inline
style="color: ..."from front matter. I chose those colors for the dark background. Inline styles beat stylesheet rules, so in light mode a.category-linkclass withcolor: var(--c-primary) !importantoverrides them. .text-white. The templates use Bootstrap’s.text-whiteto mean “foreground”. In light mode, I remap it tovar(--c-fg-strong)instead of editing every template.
Issue 1: Invisible Text in the Contact Form
Symptom
After the theme went live, the contact page in light mode showed the Name, Email, Subject, and Message fields as dark boxes on the light page. When I typed into them, nothing showed. The text was there, but it had no contrast.
Cause
Bootstrap’s .form-control rule compiled to literal colors:
.form-control{...;color:#212529;background-color:#17212b;...}
# from Bootstrap's _form-control.scss: background-color: $input-bg
# $input-bg defaults to $body-bg, which the theme sets to #17212B
Sass resolves $input-bg at build time, so the CSS carries #17212b as a fixed value. Setting --bs-body-bg in light mode does nothing for this rule, because it never reads that custom property.
My own rule in _main.scss already switched the text color to the token:
.form-control {
color: var(--c-fg-strong) !important;
// no background-color here
}
In dark mode that is white text on #17212b, as before. In light mode, --c-fg-strong becomes #17212b, so the text was #17212b on a #17212b background: a contrast ratio of exactly 1:1. Converting the text color without the background is what turned a dark-looking field into an unusable one.
Fix
One line in the theme’s .form-control rule:
.form-control {
border: 1px solid var(--c-border);
color: var(--c-fg-strong) !important;
// Bootstrap compiles its input background to a literal dark color
background-color: var(--c-bg);
&:focus {
background-color: var(--c-input-focus);
}
}
The theme’s rule comes after Bootstrap’s in the stylesheet, so it wins at equal specificity. In dark mode, --c-bg is #17212b, the same color as before, so nothing changes there.
To check it, I built the site. I made two copies of contact/index.html, with the head script forced to light in one and dark in the other. Then I filled the fields with a script and took headless Chrome screenshots of both. One showed dark text on light fields, and the other showed white text on dark fields.
Lessons
- CSS custom properties only reach rules that read them. Every Sass variable in a framework you did not write is a literal color in the output. After converting your own SCSS, search the compiled CSS for the old hex values:
grep -oiE '[^}]*#17212b[^}]*' style.csslists every rule that still hard-codes the background. - Convert colors in pairs. A foreground token next to a literal background works in one theme and breaks in the other. Whenever a rule sets
color, make sure itsbackground-colorcomes from the same theme. - Name tokens by role, not by value.
--c-fg-strongcan be white or near-black.--c-whitecannot, and the code reads wrong in one of the two themes. - Set the theme inline, before the stylesheet. A deferred script applies the theme after the first paint and flashes the wrong one. Ten lines in
<head>avoid that, and thetryaroundlocalStoragekeeps private browsing working. - Test with real input, not only page screenshots. The page itself looked right in light mode. Only filling in the form showed the bug.
