From 34bcd18d01535cffaba042b96e7ddd02b696e563 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:58:23 +0200 Subject: [PATCH 1/3] fix(theme): animate the color mode switch with one shared transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body and the navbar each declared their own transition for a color mode switch. The body omitted a timing function, so it defaulted to ease, while the navbar asked for ease-in-out. Both ran for 0.5s and therefore started and ended together, but they drifted by up to 77/255 in between, with the body running ahead. A transparent navbar was worse off: its own rule shares the specificity of the color mode rule but comes later, so it replaced the transition altogether and animated over 0.3s. Define the transition once as $theme-mode-transition and use it for every element that repaints on a switch. The transparent navbar keeps the faster curve for its backdrop filter, which is a scroll effect rather than a color mode one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/scss/common/_styles.scss | 2 +- assets/scss/common/_variables-dart.scss | 3 +++ assets/scss/common/_variables.scss | 5 +++++ assets/scss/components/_navbar.scss | 7 +++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/assets/scss/common/_styles.scss b/assets/scss/common/_styles.scss index 299125e41..d73fecacf 100644 --- a/assets/scss/common/_styles.scss +++ b/assets/scss/common/_styles.scss @@ -1,6 +1,6 @@ @if $enable-dark-mode { [data-bs-theme-animate="true"] body { - transition: background-color 0.5s, color 0.5s; + transition: $theme-mode-transition; } } diff --git a/assets/scss/common/_variables-dart.scss b/assets/scss/common/_variables-dart.scss index 149c3781e..2ee5e5848 100644 --- a/assets/scss/common/_variables-dart.scss +++ b/assets/scss/common/_variables-dart.scss @@ -51,6 +51,9 @@ $link-color-dark: mix(white, h.$primary, h.$dark-mode-tint) !d $primary-bg-subtle-dark: mix(black, h.$primary, h.$dark-mode-shade) !default; $primary-border-subtle-dark: mix(black, h.$primary, calc(h.$dark-mode-shade / 2)) !default; +// Shared by every element that repaints on a color mode switch, so they stay in sync. +$theme-mode-transition: background-color .5s ease-in-out, color .5s ease-in-out, border-color .5s ease-in-out !default; + $dropdown-transition: opacity .15s ease-in-out !default; $dropdown-horizontal-margin-top: calc((-1.5 * 1rem) - 2px); $dropdown-horizontal-padding-y: calc(1rem + 2px); diff --git a/assets/scss/common/_variables.scss b/assets/scss/common/_variables.scss index 75aad7a61..1830b6f0c 100644 --- a/assets/scss/common/_variables.scss +++ b/assets/scss/common/_variables.scss @@ -45,6 +45,11 @@ $primary-bg-subtle-dark: mix(black, $primary, $dark-mode-shade) !defa $primary-border-subtle-dark: mix(black, $primary, $dark-mode-shade / 2) !default; // scss-docs-end color-mode +// scss-docs-start color-mode-transition +// Shared by every element that repaints on a color mode switch, so they stay in sync. +$theme-mode-transition: background-color .5s ease-in-out, color .5s ease-in-out, border-color .5s ease-in-out !default; +// scss-docs-end color-mode-transition + // scss-docs-start horizontal-nav $dropdown-transition: opacity .15s ease-in-out !default; $dropdown-horizontal-margin-top: calc((-1.5 * 1rem) - 2px); diff --git a/assets/scss/components/_navbar.scss b/assets/scss/components/_navbar.scss index 02a09c0bf..d592ea658 100644 --- a/assets/scss/components/_navbar.scss +++ b/assets/scss/components/_navbar.scss @@ -85,7 +85,7 @@ @if $enable-dark-mode { [data-bs-theme-animate="true"] .navbar { - transition: 0.5s ease-in-out; + transition: $theme-mode-transition; } } @@ -102,7 +102,10 @@ .navbar[data-transparent="true"] { backdrop-filter: none; - transition: all 0.3s ease; + + // this rule would otherwise shadow the color mode transition, as it shares its + // specificity but comes later; keep the blur on its own, faster curve + transition: $theme-mode-transition, backdrop-filter 0.3s ease; &.navbar-scrolled { backdrop-filter: blur(10px); From 369b32a507bd224b3686d98bd5c091faa7000d0f Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:58:50 +0200 Subject: [PATCH 2/3] fix(navbar): assign the theme the navbar resolved for itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the top of a page without an overlay, the navbar assigned defaultTheme, which is null there, so setAttribute stored the string "null". The guard above it already computed targetTheme, which falls back to the stored color mode. Assign that instead. The invalid value happened to be harmless, as the navbar then inherited the color mode from the document, but it left the navbar claiming a theme that does not exist. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/js/navbar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/navbar.js b/assets/js/navbar.js index e7e43cc8e..427e424e9 100644 --- a/assets/js/navbar.js +++ b/assets/js/navbar.js @@ -167,7 +167,7 @@ function updateNavbar () { const targetTheme = defaultTheme ? defaultTheme : storedTheme if (targetTheme) { - navbar.setAttribute('data-bs-theme', defaultTheme) + navbar.setAttribute('data-bs-theme', targetTheme) } } } From 4c5fab631c238442f78c129f936eb22be60d249a Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:59:46 +0200 Subject: [PATCH 3/3] fix(navbar): switch the navbar color mode along with the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navbar carries its own data-bs-theme, so it only repaints once that attribute is reassigned. On a color mode switch that reassignment was deferred by 600ms, which is longer than the 0.5s transition of the body. The body therefore completed its fade before the navbar started its own, leaving the two visibly out of step. The delay was there because the navbar samples the background beneath it and would otherwise read a color the body is still fading through. Read that color from the --bs-body-bg custom property instead: custom properties are not animated, so it holds the target color from the outset. Sampling is now independent of the transition, so the navbar can update immediately and the delay can go, along with the sleep helper that served it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/js/navbar.js | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/assets/js/navbar.js b/assets/js/navbar.js index 427e424e9..2db93bbfc 100644 --- a/assets/js/navbar.js +++ b/assets/js/navbar.js @@ -6,10 +6,6 @@ const colorsBG = ['body', 'secondary', 'tertiary'] let scrollPosition = 0 -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) -} - function getStyle(el, styleProp) { let y @@ -65,7 +61,10 @@ function getBackgroundColor (section) { // use body background when section background is undefined or transparent if (color === 'rgba(0, 0, 0, 0)' || color === 'transparent') { - color = window.getComputedStyle(document.body).getPropertyValue('background-color') + const style = window.getComputedStyle(document.body) + // custom properties are not animated, so this yields the target color of a color mode + // switch rather than the intermediate color the body is currently fading through + color = style.getPropertyValue('--bs-body-bg').trim() || style.getPropertyValue('background-color') } return color @@ -126,6 +125,18 @@ function parseRGB (color) { b: parseInt(match[3]) } } + + // custom properties such as --bs-body-bg resolve to a hex color + const hex = color.match(/^#([\da-f]{3}|[\da-f]{6})$/i) + if (hex) { + const digits = hex[1].length === 3 ? hex[1].replace(/./g, c => c + c) : hex[1] + return { + r: parseInt(digits.slice(0, 2), 16), + g: parseInt(digits.slice(2, 4), 16), + b: parseInt(digits.slice(4, 6), 16) + } + } + return null } @@ -214,12 +225,9 @@ if (navbar !== null && togglers !== null) { attributeFilter: ['data-bs-theme'] } const Observer = new MutationObserver(() => { - if (fixed) { - // wait for the theme animation to finish - sleep(600).then(() => { - updateNavbar() - }) - } + // switch along with the body rather than after it; the colors sampled by updateNavbar + // come from custom properties, which hold their target value from the outset + fixed && updateNavbar() }) Observer.observe(html, config)