Using color-scheme and light-dark() to implement dark mode (#154)

* Lint fixes.

* Implement light-dark().

* Lint css with biome, remove stylelint.

* Biome lint fixes.

* Dark mode adjustments.

* Style bug fixes.

* Colour adjustments.

* Move header and footer styles to base.

* Site header adjustments.

* Removing jQuery and umbrella libs. They where great but not needed anymore.

* Add colorScheme to optionally lock light/dark.

* Update README.

* Biome adjustments.
This commit is contained in:
Fredrik Jonsson 2026-05-28 08:18:04 +02:00 • committed by GitHub
commit 16c47eea9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 276 additions and 2130 deletions

View file

@ -1,16 +1,7 @@
repos:
- repo: https://github.com/awebdeveloper/pre-commit-stylelint
rev: "0.0.2"
hooks:
- id: stylelint
files: assets/css/.*\.css$
additional_dependencies:
- "stylelint@17.4.0"
- "stylelint-config-standard@40.0.0"
- repo: https://github.com/biomejs/pre-commit
rev: "v2.4.6"
rev: "v2.4.15"
hooks:
- id: biome-check
files: assets/js/.*\.js$
additional_dependencies:
- "@biomejs/biome@2.4.6"
- "@biomejs/biome@2.4.15"

View file

@ -1 +0,0 @@
libs/

View file

@ -1,24 +0,0 @@
extends:
- stylelint-config-standard
rules:
alpha-value-notation: number
block-no-empty: null
declaration-block-no-redundant-longhand-properties: null
declaration-empty-line-before: null
comment-empty-line-before:
- always
- except:
- first-nested
ignore:
- stylelint-commands
- after-comment
hue-degree-notation: number
import-notation: null
keyframes-name-pattern:
- "--.+"
- message: Keyframe name should be written as <dashed-ident>
media-feature-range-notation: prefix
no-descending-specificity: null
selector-class-pattern:
- "^[a-z0-9\\-\\_]+$"
- message: Selector should be written in lowercase with hyphens or underscore (selector-class-pattern)

View file

@ -42,7 +42,6 @@ Quickstart a new site with the Zen theme by using the [Zen demo repo as a templa
* [Podcast](#podcast)
* [Shortcodes](#shortcodes)
* [Content security policy headers](#Content-security-policy-headers)
* [Use npm to lint CSS and JavaScript](#use-npm-to-lint-css-and-javascript)
* [Repository links](#repository-links)
* [Math typesetting with KaTeX](#math-typesetting-with-katex)
* [Getting help](#getting-help)
@ -58,6 +57,7 @@ Quickstart a new site with the Zen theme by using the [Zen demo repo as a templa
* Commands for linting of css and js
* Contact form (PHP)
* CSS grid and flex throughout
* Dark and light mode
* HTML5
* Hugo Pipes for images, js and css
* Math typesetting with KaTeX
@ -245,8 +245,8 @@ hugo server --themesDir ../..
Performance should be excellent.
* Minimal and compliant HTML5
* Styles 23,2 kB (6,7 kB when gzipped)
* JavaScript 1 kB (with only mobile menu active, 4 Kb with all features active)
* Styles 28 kB (8 kB when gzipped)
* JavaScript 6 kB (with everything active, otherwise near zero)
* All scripts loaded in head with "defer"
* Optimised for HTTP/2
@ -292,6 +292,7 @@ params:
- section2 # in the <schema.org> `JSON+LD` in the page's `<head>`.
- section3 # This theme requires Hugo `v0.109.0` or higher to
# generate the `BreadcrumbListing`.
colorScheme: "light" # "light/dark" to lock, omit for user system preference.
cookieConsent: true # Show cookie consent form, default false.
contact: "info@example.org"
dateformat: "" # Set the date format, default to "2 January, 2006"
@ -303,7 +304,6 @@ params:
image: "" # Relative path to site image in JSON-LD, no leading slash.
imageMaxWidth: "" # Max width for images added via figure shortcode.
internalPagination: true # Use Hugo's internal pagination template, default false.
jquery: true # Add jQuery, default false.
languageDir: "" # Set ltr or rtl, defaults to ltr.
logo: false # Turn off the logo, defaults to true.
logoPath: # Relative path to site logo, defaults to "images/logo.png", no leading slash.
@ -329,7 +329,6 @@ params:
siteName: false # Hide the site name (visually-hidden), default true.
submitted: true # Show author and date information for a post.
themeColor: # Hex color value, used in meta tags, default "#ffffff".
umbrella: true # Add Umbrella JS, default false.
podcast:
title: # * Feed title, defaults to site title (iTunes).
@ -374,7 +373,7 @@ They are found in the theme `assets/css/_colors.css` and `assets/css/_variables.
In most cases you likely only need to override a few values and then it is easier to set these in the custom css files.
Root `assets/css/_custom.css`: Loaded last so use to override css variables and add any custom styles you need.
Root `assets/css/_custom.css`: Loaded last so use it to override css variables and add any custom styles you need.
### Logo
@ -390,13 +389,11 @@ Upload your image to [RealFaviconGenerator](https://realfavicongenerator.net/) t
The theme will autodetect them and add the needed code.
### Head and footer partials
### Dark and light mode
If you create partials named `head/extra.html` and/or `footer.html` they will be used. They do not exist or are empty in the theme but are supported as a convenience.
Colors are set with the css function `light-dark()`. The first colour is used in light mode and the other colour is used in dark mode. See `assets/css/_colors.css`.
Content in the "head/extra" partial will be added to the end of the "head" tag, perhaps some extra css or javascript.
Content in the "footer" partial will replace all the default content in the "footer" tag.
Use the param `colorScheme` to lock your site to either light or dark mode. By default the mode will follow the users system preferences.
### Layouts
@ -432,10 +429,6 @@ There is an `assets/css/_custom.css` file meant for your custom styles. Copy it
The default styles in `assets/css/_zen.css` are boring but functional. You can easily override them completely by placing an empty file named "_zen.css" in root assets/css directory.
The css files are by default built for production, compressed with fingerprint.
By setting the Hugo environment variable to "development" (default when running `hugo server`) they will instead be nested with sourcemaps.
## Render hook templates
@ -835,27 +828,6 @@ The **content** that should be wrapped. Some more content.
Includes tracking code for Matomo or Google in a way that supports Content security policy headers. Read more in my blog post [Content security policy headers when using Matomo or Google analytics](https://xdeb.org/post/2020/01/14/content-security-policy-headers-when-using-matomo-or-google-analytics/).
## Use npm to lint CSS and JavaScript
* Lint your CSS using stylelint.
* Lint your JavaScript using biome.
* Can lint files in the theme as well as the project assets directory.
Set up your front-end development build tools:
1. Install Node.js and npm, the Node.js package manager.
2. The package.json file in your new sub-theme contains the versions of all the
Node.js software you need. To install them run:
npm install
3. Run the following commands to lint your theme and projects CSS and JavaScript code.
npm run lint-theme
npm run lint-project
nmp run lint (project + theme)
## Repository links
Add view and edit repo links on your post. Activates when you add repository settings, see example for GitHub below. See more in the partial `repository-links.html`.

View file

@ -1,97 +1,152 @@
/* biome-ignore-all format: aligned columns for readability */
/* Theme colours. */
/* Override css variables in "assets/css/_custom.css" */
:root {
/* Brand colours. */
--color-brand: var(--color-grey-900);
--color-brand-alt: var(--color-grey-500);
/* Brand variations */
--color-brand-900: oklch(from var(--color-brand) calc(l - 0.5) c h);
--color-brand-800: oklch(from var(--color-brand) calc(l - 0.4) c h);
--color-brand-700: oklch(from var(--color-brand) calc(l - 0.3) c h);
--color-brand-600: oklch(from var(--color-brand) calc(l - 0.2) c h);
--color-brand-500: oklch(from var(--color-brand) calc(l - 0.1) c h);
--color-brand-400: oklch(from var(--color-brand) l c h);
--color-brand-300: oklch(from var(--color-brand) calc(l + 0.1) c h);
--color-brand-200: oklch(from var(--color-brand) calc(l + 0.2) c h);
--color-brand-100: oklch(from var(--color-brand) calc(l + 0.3) c h);
/* Brand alt variations */
--color-brand-alt-900: oklch(from var(--color-brand-alt) calc(l - 0.5) c h);
--color-brand-alt-800: oklch(from var(--color-brand-alt) calc(l - 0.4) c h);
--color-brand-alt-700: oklch(from var(--color-brand-alt) calc(l - 0.3) c h);
--color-brand-alt-600: oklch(from var(--color-brand-alt) calc(l - 0.2) c h);
--color-brand-alt-500: oklch(from var(--color-brand-alt) calc(l - 0.1) c h);
--color-brand-alt-400: oklch(from var(--color-brand-alt) l c h);
--color-brand-alt-300: oklch(from var(--color-brand-alt) calc(l + 0.1) c h);
--color-brand-alt-200: oklch(from var(--color-brand-alt) calc(l + 0.2) c h);
--color-brand-alt-100: oklch(from var(--color-brand-alt) calc(l + 0.3) c h);
/* Base colours. */
--color-black: oklch(1% 0.01 0);
--color-nearblack: oklch(25% 0.01 0);
--color-grey-700: oklch(38% 0 0);
--color-grey-600: oklch(49% 0 0);
--color-grey-500: oklch(60% 0 0);
--color-grey-400: oklch(71% 0 0);
--color-grey-300: oklch(80% 0 0);
--color-grey-200: oklch(89% 0 0);
--color-grey-900: oklch(21% 0.01 0);
--color-grey-800: oklch(28% 0.01 0);
--color-grey-700: oklch(37% 0.01 0);
--color-grey-600: oklch(45% 0.01 0);
--color-grey-500: oklch(55% 0.01 0);
--color-grey-400: oklch(71% 0.01 0);
--color-grey-300: oklch(87% 0.01 0);
--color-grey-200: oklch(93% 0.01 0);
--color-grey-100: oklch(95% 0 0);
--color-nearwhite: oklch(98% 0 0);
--color-white: oklch(100% 0 0);
--color-white: oklch(99% 0 0);
--color-red: oklch(65% 0.2 24);
--color-yellow: oklch(65% 0.12 80);
--color-green: oklch(65% 0.19 138);
--color-blue: oklch(65% 0.16 247);
/* Transparent colours. */
--color-black-60: oklch(0% 0 0 / 0.6);
--color-black-40: oklch(0% 0 0 / 0.4);
--color-black-20: oklch(0% 0 0 / 0.2);
--color-black-10: oklch(0% 0 0 / 0.1);
--color-white-20: oklch(100% 0 0 / 0.2);
--color-white-10: oklch(100% 0 0 / 0.1);
/* Brand colours. */
--color-brand: var(--color-nearblack);
--color-brand-alt: var(--color-grey-500);
--color-brand-light: var(--color-grey-300);
--color-brand-dark: var(--color-grey-700);
--color-black-90: oklch(1% 0.01 0 / 0.9);
--color-black-80: oklch(1% 0.01 0 / 0.8);
--color-black-70: oklch(1% 0.01 0 / 0.7);
--color-black-60: oklch(1% 0.01 0 / 0.6);
--color-black-50: oklch(1% 0.01 0 / 0.5);
--color-black-40: oklch(1% 0.01 0 / 0.4);
--color-black-30: oklch(1% 0.01 0 / 0.3);
--color-black-20: oklch(1% 0.01 0 / 0.2);
--color-black-10: oklch(1% 0.01 0 / 0.1);
--color-white-20: oklch(99% 0 0 / 0.2);
--color-white-10: oklch(99% 0 0 / 0.1);
/* Text colours. */
--color-text: var(--color-black);
--color-text-bg: var(--color-white);
--color-text-meta: var(--color-grey-600);
--color-headings: var(--color-nearblack);
--color-code: var(--color-nearblack);
--color-text: light-dark(var(--color-black), var(--color-white));
--color-text-bg: light-dark(var(--color-white), var(--color-black));
--color-text-meta: light-dark(var(--color-grey-600), var(--color-grey-300));
--color-headings: light-dark(var(--color-grey-900), var(--color-grey-100));
--color-code: light-dark(var(--color-grey-900), var(--color-grey-100));
/* Link colours. */
--color-link: var(--color-brand);
--color-link-visited: var(--color-brand);
--color-link-hover: var(--color-brand-alt);
--color-link-active: var(--color-red);
--color-link-inverted: var(--color-white);
--color-link-visited-inverted: var(--color-white);
--color-link-hover-inverted: var(--color-white);
--color-link-active-inverted: var(--color-red);
--color-link: light-dark(var(--color-brand-500), var(--color-grey-100));
--color-link-visited: light-dark(var(--color-brand-500), var(--color-grey-100));
--color-link-hover: light-dark(var(--color-brand-200), var(--color-brand-400));
--color-link-active: light-dark(var(--color-red), var(--color-red));
/* Button colours. */
--color-button: var(--color-brand);
--color-button-hover: var(--color-brand-alt);
--color-button-text: var(--color-white);
--color-button-text-hover: var(--color-white);
--color-button-disabled: var(--color-grey-400);
--color-button: light-dark(var(--color-brand-400), var(--color-brand-100));
--color-button-text: light-dark(var(--color-white), var(--color-white));
--color-button-hover: light-dark(var(--color-brand-200), var(--color-brand-300));
--color-button-text-hover: light-dark(var(--color-white), var(--color-white));
/* Disabled colours. */
--color-disabled: light-dark(var(--color-grey-400), var(--color-grey-600));
--color-disabled-alt: light-dark(var(--color-grey-100), var(--color-grey-800));
/* Border colours. */
--color-border: var(--color-brand);
--color-border-light: var(--color-brand-light);
--color-border-dark: var(--color-brand-dark);
--color-border: light-dark(var(--color-brand-400), var(--color-brand-200));
--color-border-light: light-dark(var(--color-brand-200), var(--color-brand-400));
--color-border-dark: light-dark(var(--color-brand-600), var(--color-brand-300));
/* Background colours. */
--color-header-bg: var(--color-white);
--color-body-bg: var(--color-white);
--color-footer-bg: var(--color-white);
--color-backdrop: var(--color-grey-100);
--color-body-bg: light-dark(var(--color-white), var(--color-black));
--color-page-bg: light-dark(transparent, transparent);
--color-header-bg: light-dark(transparent, transparent);
--color-footer-bg: light-dark(transparent, transparent);
--color-backdrop: light-dark(var(--color-black-60), var(--color-black-80));
/* Mobil menu colours. */
--color-mobile-menu: var(--color-brand);
--color-mobile-menu-cover: var(--color-black-20);
--color-mobile-menu: light-dark(var(--color-brand-400), var(--color-brand-400));
--color-mobile-menu-cover: light-dark(var(--color-black-20), var(--color-black-20));
/* Form colours. */
--color-accent: var(--color-brand);
--color-accent: light-dark(var(--color-brand-400), var(--color-brand-200));
/* Row (e.g. in table) colours. */
--color-row-header: transparent;
--color-row-odd: var(--color-nearwhite);
--color-row-even: transparent;
--color-row-header: light-dark(transparent, transparent);
--color-row-odd: light-dark(var(--color-grey-100), var(--color-grey-900));
--color-row-even: light-dark(transparent, transparent);
/* Highlight colours. */
--color-highlight: light-dark(var(--color-brand-400), var(--color-brand-100));
--color-mark-highlight: light-dark(var(--color-red), var(--color-red));
/* Status colours. */
--color-info: var(--color-blue);
--color-info-bg: oklch(from var(--color-blue) 100% 0.03 h);
--color-success: var(--color-green);
--color-success-bg: oklch(from var(--color-green) 100% 0.03 h);
--color-success: var(--color-green);
--color-warning: var(--color-yellow);
--color-warning-bg: oklch(from var(--color-yellow) 100% 0.03 h);
--color-error: var(--color-red);
--color-error-bg: oklch(from var(--color-red) 100% 0.03 h);
/* Highlight colours. */
--color-highlight: var(--color-brand-alt);
--color-mark-highlight: var(--color-red);
/* Follow user system dark/light setting. */
color-scheme: light dark;
/* Initialize the theme variable */
--theme: light;
@media (prefers-color-scheme: dark) {
--theme: dark;
}
}
[data-theme="light"] {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}
@container style(--theme: light) {
[data-theme="inverted"] {
color-scheme: dark;
}
}
@container style(--theme: dark) {
[data-theme="inverted"] {
color-scheme: light;
}
}

View file

@ -1,3 +1,5 @@
/* biome-ignore-all format: aligned columns for readability */
:root {
/* Set theme css variables.
Override css variables in "assets/css/_custom.css" */
@ -39,8 +41,6 @@
monospace) hints at what type of font to use if the web browser doesn't
find any of the fonts in the list. */
/* stylelint-disable value-keyword-case */
/* Emojis font stack. Add to end of other stacks when needed. */
--emojis: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
@ -69,7 +69,6 @@
/* Monospace font stacks. */
--ui-monospace: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
/* stylelint-enable value-keyword-case */
/* Font family. */
--ff-body: var(--ui-serif);

View file

@ -2,14 +2,6 @@
/* Page */
body {
background-color: var(--color-backdrop);
}
.page {
background-color: var(--color-body-bg);
}
.footer {
border-block-start: 2px solid var(--color-border);
}

View file

@ -1,21 +1,31 @@
/* Document */
html {
/* Change the default font family in all browsers. */
font-family: var(--ff-body);
/* Set base font size in percent. */
font-size: calc(var(--base-font-size) / 16 * 100%);
/* Set text colour. */
color: var(--color-text);
}
body {
/* Change the default font family in all browsers. */
font-family: var(--ff-body);
/* Correct the line height in all browsers. */
line-height: var(--base-line-height);
/* Set text colour. */
color: var(--color-text);
/* Accent colour for forms. */
accent-color: var(--color-accent);
/* Set background colour. */
background-color: var(--color-body-bg);
}
.page {
background-color: var(--color-page-bg);
}
::backdrop {
background-color: var(--color-backdrop);
}

View file

@ -0,0 +1,11 @@
/* Branding footer */
.footer {
padding-block: var(--gutters);
background-color: var(--color-footer-bg);
text-align: center;
p {
margin: 0;
}
}

View file

@ -16,7 +16,7 @@ label {
/* Display as block if before input. */
&:has(+ input),
&:has(+ textarea){
&:has(+ textarea) {
display: block;
}

View file

@ -0,0 +1,55 @@
/* Branding header */
.header {
--header-gap: var(--spacing-s);
padding-block: min(var(--gutters), var(--spacing-m));
display: flex;
flex-direction: row;
align-items: center;
gap: var(--header-gap);
background-color: var(--color-header-bg);
@media (min-width: 666px) {
flex-direction: row;
align-items: center;
}
}
/* Wrapping link for logo. */
.header__logo {
width: fit-content;
}
/* Logo image. */
.header__logo-image {
vertical-align: bottom;
min-width: 32px;
}
/* The name of the website. */
.header__site-name {
margin: 0;
line-height: 1;
}
/* The link around the name of the website. */
.header__site-link {
&:link,
&:visited {
color: var(--color-text);
text-decoration: none;
}
&:hover,
&:focus {
text-decoration: underline;
}
}
/* Wrapper for any blocks placed in the header region. */
.header__region {
@media (min-width: 666px) {
margin-inline-start: auto;
}
}

View file

@ -8,7 +8,7 @@ h3,
h4,
h5,
h6 {
margin-block: 1.5em .5em;
margin-block: 1.5em 0.5em;
color: var(--color-headings);
font-family: var(--ff-headings);
font-weight: var(--fw-headings);
@ -17,7 +17,7 @@ h6 {
/* Smaller margin when a headeing is right after another heading. */
& + & {
margin-block-start: .75em;
margin-block-start: 0.75em;
}
/* Set max text line width in main for readability. */
@ -31,17 +31,17 @@ h1 {
margin-block-start: var(--spacing-m);
margin-block-end: var(--spacing-xs);
font-size: var(--fs-xxl);
letter-spacing: -.01em;
letter-spacing: -0.01em;
}
h2 {
font-size: var(--fs-xl);
letter-spacing: -.01em;
letter-spacing: -0.01em;
}
h3 {
font-size: var(--fs-l);
letter-spacing: -.01em;
letter-spacing: -0.01em;
}
h4 {

View file

@ -3,7 +3,7 @@
figure,
img {
&.image {
--image-padding: .75rem;
--image-padding: 0.75rem;
@media (min-width: 666px) {
&.center {

View file

@ -55,25 +55,3 @@ a:active {
}
}
}
/* Inverted links, for dark backgrounds. */
.link-inverted {
a {
&:link {
color: var(--color-link-inverted);
}
&:visited {
color: var(--color-link-visited-inverted);
}
&:hover,
&:focus {
color: var(--color-link-hover-inverted);
}
&:active {
color: var(--color-link-active-inverted);
}
}
}

View file

@ -1,8 +1,8 @@
/* Anchor */
.anchor {
--anchor-gap: .5rem;
--anchor-opacity: .4;
--anchor-gap: 0.5rem;
--anchor-opacity: 0.4;
display: grid;
grid-template-columns: auto auto;

View file

@ -33,32 +33,3 @@
.box--gutter {
padding-inline: var(--gutters);
}
.box--inverted {
background: var(--color-border);
color: var(--color-text-bg);
/* Make sure headings etc. also gets inverted. */
* {
color: var(--color-text-bg);
}
a {
&:link {
color: var(--color-link-inverted);
}
&:visited {
color: var(--color-link-visited-inverted);
}
&:hover,
&:focus {
color: var(--color-link-hover-inverted);
}
&:active {
color: var(--color-link-active-inverted);
}
}
}

View file

@ -1,5 +1,3 @@
/* stylelint-disable no-duplicate-selectors */
/* Button */
.button,
@ -11,7 +9,7 @@
text-decoration: none;
text-align: center;
width: fit-content;
padding: .2rem 1rem;
padding: 0.2rem 1rem;
/* Improve usability and consistency of cursor style between image-type `input` and others. */
cursor: pointer;
@ -36,7 +34,7 @@
.button--small {
font-size: var(--fs-xs);
padding: .2rem .75rem;
padding: 0.2rem 0.75rem;
}
.button--alt {
@ -65,7 +63,7 @@
&:active {
color: var(--color-button-text);
background: var(--color-success);
}
}
}
.button--decline {
@ -98,18 +96,19 @@ other variations. */
[type='button'],
[type='submit'] {
&:disabled {
background-color: var(--color-grey-200);
border: 1px solid var(--color-button-disabled);
color: var(--color-disabled);
background-color: var(--color-disabled-alt);
border: 1px solid var(--color-disabled);
background-image: none;
text-shadow: none;
cursor: default;
color: var(--color-button-disabled);
&:hover,
&:focus,
&:active {
/* Override any link underlines and color changes. */
color: var(--color-button-disabled);
color: var(--color-disabled);
background-color: var(--color-disabled-alt);
text-decoration: none;
}
}
@ -127,7 +126,7 @@ textarea {
@media (min-width: 666px) {
input:only-of-type {
&:has(+ [type='submit']) {
margin-inline-end: .5rem;
margin-inline-end: 0.5rem;
}
}
}

View file

@ -42,27 +42,3 @@ A simple cards style. */
.card--gutter {
padding-inline: var(--gutters);
}
.card--inverted {
background: var(--color-border);
color: var(--color-text-bg);
a {
&:link {
color: var(--color-link-inverted);
}
&:visited {
color: var(--color-link-visited-inverted);
}
&:hover,
&:focus {
color: var(--color-link-hover-inverted);
}
&:active {
color: var(--color-link-active-inverted);
}
}
}

View file

@ -4,7 +4,7 @@
display: flex;
flex-flow: wrap;
justify-content: center;
gap: .5rem;
gap: 0.5rem;
position: sticky;
bottom: 0;
width: 100%;
@ -19,7 +19,7 @@
.cookieconsent__actions {
display: flex;
gap: .5rem;
gap: 0.5rem;
}
&[hidden] {

View file

@ -1,13 +1,13 @@
.disabled {
/* Re-set default cursor for disabled elements. */
cursor: default;
color: var(--color-button-disabled);
color: var(--color-disabled);
&:hover,
&:focus,
&:active {
/* Override any link underlines and color changes. */
color: var(--color-button-disabled);
color: var(--color-disabled);
text-decoration: none;
}
}

Some files were not shown because too many files have changed in this diff Show more