we've added light mode

This has been an addition we’ve wanted for a long while. For too many reasons to consider listing, some people find dark websites difficult to read. Or they’ve just got preferences. The Reader Mode in many browsers can help do an auto-transformation, but not everyone knows about that, and it doesn’t always do it right. So, now there’s a light theme built into the site now. If you’re a light-mode enjoyer, I hope it helps! The rest of this post will be some details about how we implemented it.

While this was a simple addition, it might be more complex than you’d think.

why we didn’t use prefers-color-scheme

First, let’s discuss the unfortunate case of the CSS prefers-color-scheme media query.

In theory, this CSS selector lets a web developer make a site work in either light or dark mode with CSS alone, no javascript needed. Let’s review the values it can take:

There is no way to differentiate between whether a user specifically wants light theme or has no particular preference. Light is, implicitly, the default per the way this works right now. This is ok if you want your site to default to light mode. But our site is, first and foremost, for ourselves. We use some browsers that don’t support prefers-color-scheme, we use OS environments where setting it up is difficult; in general we are in a lot of situations where we want to browse our site somewhere without configuration, and we want our site to look the way we prefer when we do that.

So, given that dark is the default we want, and there’s no way to differentiate between “default” and “light”, we just can’t use this feature to do anything useful.

JavaScript + localStorage

Whether or not we could use prefers-color-scheme, we’d still want an interactive override as an option. It just turned out to be the entirety of the feature in this case. We’ve got a bit of javascript at /colorscheme.js which makes it work. It’s based on the theme switcher on iliana.fyi, but tuned to our own sensibilities. I’ll reproduce the block comment from the top of the file here:

This color theme switcher is based on iliana’s switcher:

https://github.com/iliana/iliana.fyi/blob/main/src/theme.jsx

We do things a bit differently. In an ideal world, we would follows prefers-colorscheme, let users set that to set the theme to light/dark, and then use javascript as an optional override. But, for Reasons, browsers do not communicate that a reader explicitly prefers a light theme. There is either “reader wants a dark theme” or nothing.

iliana takes the philosophy of presenting a light theme by default as a consequence of this. But I tend to use browsers that don’t let you specify prefers-colorscheme, and also don’t support javascript. I want the website to look the way I want by default, since this is my personal site.

In our CSS file, we set up some CSS variables and initialize them with a :root{} block. We also define a :root.light{} block to turn on light mode. Changing colors then is performed by the presence or absence of the “light” class in the documentElement class list.

This javascript creates a button element for choosing the right theme. We generate the HTML in here so that the reader doesn’t see an option to change themes if they don’t have javascript- we wouldn’t want to make promises we can’t keep.

We load their preference out of localStorage, if it’s there. Then, whenever they change their setting with the button, we reflect that change and save it.

To avoid the dreaded “flash of unstyled content”, it’s important that this JS is run after the document exists, but before it renders. We can do this by including the script with “defer”, like this:

  <script defer src="/colorscheme.js"></script>

And, here’s the JS in its entirety:

let color = 'dark';

try {
    color = window.localStorage.color;
} catch {
    // nothing
}

if (color === 'light') {
    document.documentElement.classList.add('light')
}

/*
theming happens in the css
*/
const btn = document.createElement('button');
btn.innerText = 'Light/Dark';
btn.id = 'themeSwitcher';

/*
We need to listen for when the reader changes color scheme, and we do that here.

Update the actual color, and save it in localStorage
*/
btn.addEventListener("click", () => {
    // Toggle the theme
    if (color === 'light') {
        color = 'dark';
        document.documentElement.classList.remove('light')
    } else {
        color = 'light';
        document.documentElement.classList.add('light')
    }

    try {
        window.localStorage.color = color;
    } catch {
        // nothing
    }
});

document.body.firstElementChild.prepend(btn);

The nice thing about localStorage is it’s stored entirely client side, so I don’t need to track cookies or anything. It’s also persistent across the entire site. It can be cleared behind my back if a reader’s browser decides it needs to free up spaces, but I’ll be low on a browser’s priority for that since I’m only storing a single value.

CSS

This JS pairs with a few chunks of CSS:

:root {
  --foreground:         #fbf5ef;
  --foreground-accent:  #f2d3ab;
  --background:         #272744;
  --background-accent:  #494d7e;
  --background-code:    #494d7e;

  /* derived by palemoon from background-accent */
  --button-border-bright:  #aeb0c6;
  --button-border-dark:    #313354;
  --border-code: none;
}

:root.light {
  --foreground:        #121223;
  --foreground-accent: #15172b;
  --background:        #fbf5ef;
  --background-accent: #f2d3ab;
  --background-code:   #fefbec;

  /* derived by palemoon from background-accent */
  --button-border-bright: #fbf1e5;
  --button-border-dark:   #86755f;
  --border-code: 1px solid var(--foreground-accent);
}

By default, the CSS variables in :root will take affect, but in light mode the variables in :root.light will be set instead. The rest of the CSS is defined in terms of these variables.

The weird button-border colors are necessary because I wanted the button to have the old-style appearance of border-style: outset and border-style: inset. Firefox today gives inset and outset a much more subdued appearance, but palemoon still has the old style I was looking for. So i just took a screenshot of that, color-picked the colors out of it, and set the 4 button border colors manually to make it look right everywhere else.

I also couldn’t really justify using a darker color to differentiate code blocks from prose when part of the point of light mode was also to be a higher contrast reading option, so I went for a slightly-different color that used to be the background of this site back in 2017 or so, as a fun little reference for ourselves. It wasn’t really distinct enough for me though, so I added a border around code blocks too.

Button positioning is a little hacky, but hey it works:

/*
On wide monitors, just put it in the top-right corner. On thin monitors,
we float: right so that it reflows the nav bar text.

Our body max-width is 750px, so we add a bit onto that for margin and then
call it good
*/

@media (min-width: 908px) {
    #themeSwitcher {
        position: absolute;
        top: 1em;
        right: 1em;
    }
}

#themeSwitcher {
    float: right;
    padding: 4px;
    margin-left: 4px;
}

Maintaining support for Netsurf

We use the netsurf browser sometimes, which doesn’t currently support CSS variables. We still wanted our theme to work there though. To do this, we define all color properties twice- first with the default theme, and second with the CSS variable:

hr {
    color: $background-accent;
    color: var(--background-accent);
    background-color: $background-accent;
    background-color: var(--background-accent);
}

a {
    color: $foreground-accent;
    color: var(--foreground-accent);
}

Netsurf will see the first non-variable definition, use that, and ignore the definition with var(). More featureful browsers will overwrite the static definition with the var() definition since it comes second.

Because of this, we’re still using a CSS pre-processor, and those dollar-signs are variables that get replaced with the correct hex codes at site generation. We’re doing a rewrite of our site with a new custom site generator actually, so we might make that automatically do these double-definitions for us.

Our light theme changer won’t work on netsurf, but eh. At that level of tech, opening our website with the graphical version of the links browser, or the terminal version with a light terminal theme, would also do the trick just fine.