From 3ba6960e3960ae032308f6da480188dc6ca9e98d Mon Sep 17 00:00:00 2001 From: Jaysinh Shukla Date: Tue, 22 Sep 2026 15:33:50 +0530 Subject: [PATCH] Add dark mode with Light, Dark and System choices A header button cycles Light, Dark and System, closing #117. The choice is saved in localStorage; System, the default, follows the OS setting and switches live when it changes. Minima 2.5 compiles its colors into the CSS and runs lighten/darken on them, so dark mode is a set of override rules scoped to html[data-theme=dark]. The light CSS is unchanged. An inline script in sets the theme before first paint so dark visitors see no light flash, and without JavaScript the page follows the OS through prefers-color-scheme. Dark code blocks use rouge's github.dark colors, and every text and background pair meets WCAG AA contrast. Co-Authored-By: Claude Opus 5 --- _includes/head.html | 24 ++++- _includes/header.html | 35 +++++++ _sass/_syntax-dark.scss | 120 +++++++++++++++++++++++ _sass/dark-theme.scss | 205 ++++++++++++++++++++++++++++++++++++++++ assets/js/theme.js | 72 ++++++++++++++ assets/main.scss | 1 + 6 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 _includes/header.html create mode 100644 _sass/_syntax-dark.scss create mode 100644 _sass/dark-theme.scss create mode 100644 assets/js/theme.js diff --git a/_includes/head.html b/_includes/head.html index 18f6f2c..e18be61 100644 --- a/_includes/head.html +++ b/_includes/head.html @@ -5,7 +5,29 @@ {% if page.title %}{{ page.title | escape }}{% else %}{{ site.title | escape }}{% endif %} - + + + + + + diff --git a/_includes/header.html b/_includes/header.html new file mode 100644 index 0000000..c23172a --- /dev/null +++ b/_includes/header.html @@ -0,0 +1,35 @@ + diff --git a/_sass/_syntax-dark.scss b/_sass/_syntax-dark.scss new file mode 100644 index 0000000..05c078f --- /dev/null +++ b/_sass/_syntax-dark.scss @@ -0,0 +1,120 @@ +// Dark syntax colors: output of `bundle exec rougify style github.dark --scope .highlight` (rouge 4.7.0). +// Included from dark-theme.scss inside the dark-theme mixin. +@mixin syntax-dark { +.highlight table td { padding: 5px; } +.highlight table pre { margin: 0; } +.highlight, .highlight .w { + color: #c9d1d9; + background-color: #161b22; +} +.highlight .k, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt, .highlight .kv { + color: #ff7b72; +} +.highlight .gr { + color: #f0f6fc; +} +.highlight .gd { + color: #ffdcd7; + background-color: #67060c; +} +.highlight .nb { + color: #ffa657; +} +.highlight .nc { + color: #ffa657; +} +.highlight .no { + color: #ffa657; +} +.highlight .nn { + color: #ffa657; +} +.highlight .sr { + color: #7ee787; +} +.highlight .na { + color: #7ee787; +} +.highlight .nt { + color: #7ee787; +} +.highlight .gi { + color: #aff5b4; + background-color: #033a16; +} +.highlight .ges { + font-weight: bold; + font-style: italic; +} +.highlight .kc { + color: #79c0ff; +} +.highlight .l, .highlight .ld, .highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .il, .highlight .mo, .highlight .mx { + color: #79c0ff; +} +.highlight .sb { + color: #79c0ff; +} +.highlight .bp { + color: #79c0ff; +} +.highlight .ne { + color: #79c0ff; +} +.highlight .nl { + color: #79c0ff; +} +.highlight .py { + color: #79c0ff; +} +.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { + color: #79c0ff; +} +.highlight .o, .highlight .ow { + color: #79c0ff; +} +.highlight .gh { + color: #1f6feb; + font-weight: bold; +} +.highlight .gu { + color: #1f6feb; + font-weight: bold; +} +.highlight .s, .highlight .sa, .highlight .sc, .highlight .dl, .highlight .sd, .highlight .s2, .highlight .se, .highlight .sh, .highlight .sx, .highlight .s1, .highlight .ss { + color: #a5d6ff; +} +.highlight .nd { + color: #d2a8ff; +} +.highlight .nf, .highlight .fm { + color: #d2a8ff; +} +.highlight .err { + color: #f0f6fc; + background-color: #8e1519; +} +.highlight .c, .highlight .ch, .highlight .cd, .highlight .cm, .highlight .cp, .highlight .cpf, .highlight .c1, .highlight .cs { + color: #8b949e; +} +.highlight .gl { + color: #8b949e; +} +.highlight .gt { + color: #8b949e; +} +.highlight .ni { + color: #c9d1d9; +} +.highlight .si { + color: #c9d1d9; +} +.highlight .ge { + color: #c9d1d9; + font-style: italic; +} +.highlight .gs { + color: #c9d1d9; + font-weight: bold; +} +} \ No newline at end of file diff --git a/_sass/dark-theme.scss b/_sass/dark-theme.scss new file mode 100644 index 0000000..38bfdd6 --- /dev/null +++ b/_sass/dark-theme.scss @@ -0,0 +1,205 @@ +// Dark theme (issue #117). +// +// Minima 2.5 compiles its colors into the CSS and runs lighten()/darken() on +// them, so its Sass variables can't be swapped for CSS custom properties. +// Dark mode is therefore a set of override rules, scoped so the light CSS is +// left exactly as minima emits it. +// +// Which theme applies is decided by `data-theme` on , set before first +// paint by the script in _includes/head.html and changed by assets/js/theme.js. +// Without JavaScript there is no `data-theme`, and the media query at the +// bottom follows the OS setting instead. + +@import "syntax-dark"; + +// Every foreground/background pair below meets WCAG AA (4.5:1) or better. +$dark-background-color: #1b1b1d; // also the theme-color meta value in dark +$dark-text-color: #e3e3e3; // 13.4:1 +$dark-brand-color: #6ea8fe; // 7.1:1 +$dark-visited-color: #8fb0e0; +$dark-grey-color: #a0a0a0; // 6.6:1, post meta and footer +$dark-grey-color-light: #3a3a3d; // borders +$dark-grey-color-dark: #c8c8c8; // site title, 10.3:1 +$dark-code-background: #161b22; // matches rouge github.dark + +@mixin dark-theme { + color-scheme: dark; + + body { + color: $dark-text-color; + background-color: $dark-background-color; + } + + a { + color: $dark-brand-color; + + &:visited { + color: $dark-visited-color; + } + + &:hover { + color: $dark-text-color; + } + } + + blockquote { + color: $dark-grey-color; + border-left-color: $dark-grey-color-light; + } + + pre, + code { + border-color: $dark-grey-color-light; + background-color: $dark-code-background; + } + + hr { + border-color: $dark-grey-color-light; + } + + .svg-icon { + fill: $dark-grey-color; + } + + table { + color: #d0d0d0; + border-color: $dark-grey-color-light; + + tr:nth-child(even) { + background-color: #232326; + } + + th { + background-color: #26262a; + border-color: #444448; + border-bottom-color: #55555a; + } + + td { + border-color: $dark-grey-color-light; + } + } + + .site-header { + border-top-color: $dark-grey-color-dark; + border-bottom-color: $dark-grey-color-light; + } + + .site-title { + &, + &:visited { + color: $dark-grey-color-dark; + } + } + + .site-nav { + .page-link, + .theme-toggle { + color: $dark-text-color; + } + + @include media-query($on-palm) { + background-color: $dark-background-color; + border-color: $dark-grey-color-light; + + .menu-icon > svg { + fill: $dark-grey-color-dark; + } + } + } + + .site-footer { + border-top-color: $dark-grey-color-light; + } + + .footer-col-wrapper, + .post-meta { + color: $dark-grey-color; + } + + @include syntax-dark; + + // github.dark leaves these two unset, which would keep minima's light + // colors (#888, #555) on the dark code background. + .highlight .go { + color: #8b949e; + } + + .highlight .gp { + color: #c9d1d9; + } +} + +html[data-theme="dark"] { + @include dark-theme; +} + +// Keeps native controls and scrollbars light when a visitor picks Light on a +// dark OS; the color-scheme meta would otherwise let the browser follow the OS. +html[data-theme="light"] { + color-scheme: light; +} + +// No JavaScript: no data-theme attribute, so follow the OS setting. +@media (prefers-color-scheme: dark) { + html:not([data-theme]) { + @include dark-theme; + } +} + +/** + * Theme toggle button (in _includes/header.html) + */ +.theme-toggle { + padding: 0; + border: 0; + background: none; + color: $text-color; + font: inherit; + line-height: $base-line-height; + cursor: pointer; + vertical-align: middle; + + // The class rule would otherwise beat the UA's [hidden] { display: none }. + &[hidden] { + display: none; + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + border-radius: 3px; + } + + @include media-query($on-palm) { + margin-left: 20px; + padding: 5px 10px; + } +} + +.theme-icon { + display: none; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + vertical-align: middle; +} + +.theme-icon-fill { + fill: currentColor; +} + +html[data-theme-pref="light"] .theme-icon-light, +html[data-theme-pref="dark"] .theme-icon-dark, +html[data-theme-pref="system"] .theme-icon-system { + display: inline-block; +} + +// Colors fade only while theme.js has this class on (just after a +// click), so page loads and navigation never animate. +html.theme-transition, +html.theme-transition * { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important; +} diff --git a/assets/js/theme.js b/assets/js/theme.js new file mode 100644 index 0000000..5ca46cb --- /dev/null +++ b/assets/js/theme.js @@ -0,0 +1,72 @@ +// Theme toggle (issue #117). The inline script in _includes/head.html has +// already set data-theme before paint; this file wires up the header button +// and keeps the page in step with the OS setting and with other open tabs. +(function () { + var STORAGE_KEY = "theme"; + var ORDER = ["light", "dark", "system"]; + var LABELS = { light: "Light", dark: "Dark", system: "System" }; + // Browser toolbar color on mobile. Light keeps the value the site always had. + var THEME_COLORS = { light: "#424242", dark: "#1b1b1d" }; + + var root = document.documentElement; + var darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); + var button = document.getElementById("theme-toggle"); + var themeColorMeta = document.getElementById("theme-color-meta"); + + function readPref() { + var pref; + try { pref = localStorage.getItem(STORAGE_KEY); } catch (e) {} + return ORDER.indexOf(pref) === -1 ? "system" : pref; + } + + function savePref(pref) { + try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {} + } + + // Held in memory too, so the toggle still cycles when storage is blocked. + var currentPref = readPref(); + + function applyTheme(pref) { + currentPref = pref; + var theme = pref === "system" ? (darkQuery.matches ? "dark" : "light") : pref; + root.setAttribute("data-theme", theme); + root.setAttribute("data-theme-pref", pref); + if (themeColorMeta) themeColorMeta.setAttribute("content", THEME_COLORS[theme]); + if (button) { + var label = "Theme: " + LABELS[pref]; + button.setAttribute("aria-label", label); + button.setAttribute("title", label); + } + } + + // Fade colors for a click only; see html.theme-transition in dark-theme.scss. + var transitionTimer; + function withTransition() { + root.classList.add("theme-transition"); + clearTimeout(transitionTimer); + transitionTimer = setTimeout(function () { + root.classList.remove("theme-transition"); + }, 300); + } + + if (button) { + button.addEventListener("click", function () { + var next = ORDER[(ORDER.indexOf(currentPref) + 1) % ORDER.length]; + savePref(next); + withTransition(); + applyTheme(next); + }); + button.hidden = false; + } + + darkQuery.addEventListener("change", function () { + if (currentPref === "system") applyTheme("system"); + }); + + // Another tab changed the preference. + window.addEventListener("storage", function (event) { + if (event.key === STORAGE_KEY) applyTheme(readPref()); + }); + + applyTheme(currentPref); +})(); diff --git a/assets/main.scss b/assets/main.scss index 12f3a0b..a1c7e08 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3,6 +3,7 @@ --- @import "minima"; +@import "dark-theme"; // The About page portrait. `cover` keeps the face centred whatever aspect // ratio the source photo has, so the circle never squashes it.