Supporting Dark Mode in the Blog

I was thinking that the original theme of the blog looks nice, but is also kinda bright. So why not be modern and support a dark mode theme for users with such an activated setting?

The rough Guideline

I was looking for a tutorial how to support a dark mode Jekyll theme and luckily this blog explains it very well. I will roughly sketch the steps and move on with some more detailed explanations of the problems I faced.

The basic idea - as described in the blog above - is to convert all your colors in the CSS to use CSS-variables. Those variables are widely supported, so it should be safe to use them. Once we use such variables all over our place we can use a @media (prefers-color-scheme: dark) query to change the values of these variables if the user has a system with dark mode settings enabled. That’s it! In code this looks like

// your entry scss file
:root {
  --text-color-main: #333333;
}

@media (prefers-color-scheme: dark)  {
  :root {
    --text-color-main: #cccccc;
  }
}

html, body {
  color: var(--text-color-main);
}

This blog is based on Jekyll Now on Github, which is using a default theme with Sass and its variables. Technically I should have been able to work with these Sass variables, but I noticed that there were some parts in the code (syntaxx highlighting) that had hard-coded color values. Not so good I thought. Additionally most Frameworks I work with in my free time and during work are relying on CSS variables rather than Sass variables for their theming. So I refactored the code base slightly to CSS variables.

Being Lazy

Once I came to the Syntax Hightlighting I noticed that I don’t want to manually adjust all of these 60+ slots and replace the color values with variable names… Lazy as your usual developer I thought how to solve this problem and came up with a small script, that processes the original file row by row, produces a css variable for light mode, one for dark mode (simply inverted color),

Disclaimer: After I finished my work I noticed that I didn’t had to do this, because the default colors work kind of fine with the dark theme. Also the inverted colors are not perfect. Anyhow! 😁

The original file looked like

.highlight .c { color: #586E75 } /* Comment */
.highlight .err { color: #93A1A1 } /* Error */
.highlight .g { color: #93A1A1 } /* Generic */
// and 50+ more lines like these

First step was to slightly preprocess the file by removing all .highlight classes. Not necessary, but makes it a bit easier for the humand mind to process.

Afterwards I wrote a simple Node.js script (file ending .mjs), that reads the file line by line, extract the color value via Regex /#[A-F0-9]{6}/ and the comment value - also via Regex /\/\* [\w.]* \*\//. Both were used to generate a variable name with a color value. Afterwards the original color value was replaced by the usage of the CSS variable.

The result looks like this (truncated):

:root {
  --syntax-hightlight-color-comment: #586e75; /* Comment */
  --syntax-hightlight-color-error: #93a1a1; /* Error */
  --syntax-hightlight-color-generic: #93a1a1; /* Generic */
}

.hightlight {
  .c { color: var(--syntax-hightlight-color-comment); } /* Comment */
  .err { color: var(--syntax-hightlight-color-error); } /* Error */
  .g { color: var(--syntax-hightlight-color-generic); } /* Generic */
}

Additionally I went ahead an inverted the original color value. This can be done by subtracting the RGB values of a color from 255 (each of them). Then I put those inverted colors into the dark mode @media query in my output file. As I said above I don’t use those values for now, because they don’t match my expectations - it seems that simply inverted so many colors don’t work out well. For the basic set tho (i.e. blog texts and background) inverting works fine. Nevertheless I need to adjust that at some point in time and for that I’m grateful that I have the variables.

The following script must be saved as an .mjs file and you need a recent Node.js version (like 14 or 15) to execute it. Otherwise refactor the import statements to require and run it as usual.

// convert.mjs
import readline from 'readline';
import fs from 'fs';

const readInterface = readline.createInterface({
  input: fs.createReadStream('./input.txt'),
  output: null,
  console: false
});

let cssVarLight = '';
let cssVarDark = '';
let cssRules = '';

readInterface.on('line', line => {
  // extract color
  const colorRegex = /#[A-F0-9]{6}/;
  const lightColor = line.match(colorRegex)[0]
  const lightRgb = hexToRgb(lightColor);

  // create css variable name
  const commentRegex = /\/\* [\w.]* \*\//;
  const commentText = line.match(commentRegex)[0];
  const cssVarName = '--syntax-hightlight-color-' + commentText
    .replace('/* ', '').replace(' */', '')
    .toLowerCase()
    .replace(/\./g, '-');
    
  // print css rule
  cssRules += '\n' + line.replace(lightColor, `var(${cssVarName})`);
  // print css variable
  cssVarLight += `\n${cssVarName}: ${lightColor}; ${commentText}`;
  // dark mode
  const invertedRgb = invertRgb(lightRgb);
  const invertedColor = rgbToHex(invertedRgb);
  cssVarDark += `\n${cssVarName}: ${invertedColor}; ${commentText}`;
});

readInterface.on('close',  () => {
  fs.writeFileSync('css-rules.css', cssRules);
  fs.writeFileSync('css-vars-light.css', cssVarLight);
  fs.writeFileSync('css-vars-dark.css', cssVarDark);
});


function invertRgb(rgb) {
  return {
    r: 255 - rgb.r,
    g: 255 - rgb.g,
    b: 255 - rgb.b,
  }
}

// source: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb#answer-5624139
function hexToRgb(hex) {
  var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result ? {
    r: parseInt(result[1], 16),
    g: parseInt(result[2], 16),
    b: parseInt(result[3], 16)
  } : null;
}

function componentToHex(c) {
  var hex = c.toString(16);
  return hex.length == 1 ? "0" + hex : hex;
}

function rgbToHex(rgb) {
  return "#" + componentToHex(rgb.r) + componentToHex(rgb.g) + componentToHex(rgb.b);
}
Written on December 7, 2020
Source code on Gitlab: /blog-dark-theme.md