Skip to content

Commit 43ed492

Browse files
Release 1.4.0: root View Transitions theme crossfade with animating-class fallback.
Platform light/dark paint so extensions following tokens or html.dark-theme animate without per-widget transition rules. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7a28fbe commit 43ed492

9 files changed

Lines changed: 204 additions & 38 deletions

File tree

CHANGELOG.adoc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ Entries below are in **chronological order** by date: prior release, then the **
1414
* *Docs:* Clarify product roles — Valentus release bundle already includes dark mode; overlay / ui-module examples use Default UI (do not stack this package on Valentus `ui-bundle.zip`).
1515
* *CI:* Release workflow fixes (`v*.*.*` tag filter, `workflow_dispatch`, lockfile); npm publish via trusted publishing (OIDC) — no `NPM_TOKEN`.
1616

17+
== [1.4.0] - 2026-08-01
18+
19+
* *Feature:* Root light↔dark theme transition — View Transitions API on toggle when supported; fallback temporary `html.adt-theme-animating` with a universal paint transition so extension chrome following tokens / `html.dark-theme` animates without per-widget rules. Honors `prefers-reduced-motion: reduce`. Documented as the extension contract in Customization / How It Works.
20+
* See link:changelog-details/2026-08-01%20-%20root%20theme%20transition.adoc[Detailed notes — 2026-08-01 root theme transition].
21+
1722
== [1.3.1] - 2026-06-13
1823

1924
* *Toggle icon:* Playbook `site.keys.dark_mode_navbar` (`dark` | `light`) selects light-mode navbar tone for the theme toggle — dark top bar (Antora default, white icon) vs. light top bar (dark icon). Sets `meta[name=dm-navbar]` and `html[data-dm-navbar]`.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
= 2026-08-01 — Root theme transition
2+
3+
* *Problem:* Per-component `transition:` rules do not scale. Extensions paint their own backgrounds; when CSS variables / `html.dark-theme` flip, only elements that declared their own transitions animate.
4+
* *Platform fix (`site-dark-mode.js` + `site-extra.css`):*
5+
** Preferred: `document.startViewTransition` around the theme class flip (skipped when `prefers-reduced-motion: reduce`).
6+
** Fallback: add `html.adt-theme-animating` for ~300ms with a universal transition on paint properties (`background-color`, `color`, `border-color`, `fill`, `stroke`, `box-shadow`, `filter`, `opacity`).
7+
* *Contract:* Extensions style via tokens / `html.dark-theme`; they should not invent their own toggle animation.
8+
* *Consumers:* Valentus vendors this via `pnpm dark-mode:sync`; demo at the GitHub Pages site for this repo.

docs/modules/guide/pages/customization.adoc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ html.dark-theme .my-custom-element {
7575
}
7676
----
7777

78+
[[theme-transition-contract]]
79+
== Theme transition contract (extensions)
80+
81+
Light↔dark is animated at the *platform* root — not per widget.
82+
83+
* Preferred: View Transitions API (`document.startViewTransition`) in `site-dark-mode.js` when the browser supports it and the user has not requested reduced motion.
84+
* Fallback: temporary `html.adt-theme-animating` for ~300ms with a universal paint transition (`background-color`, `color`, `border-color`, `fill`, `stroke`, `box-shadow`, `filter`, `opacity`).
85+
* Accessibility: both paths are skipped when `prefers-reduced-motion: reduce`.
86+
87+
*Extension authors:* style chrome with CSS custom properties and/or `html.dark-theme` selectors so surfaces pick up the root animation.
88+
Do *not* invent a separate toggle animation for your widget — per-component `transition:` rules do not scale when other extensions paint their own backgrounds.
89+
7890
== Toggle Button Position
7991

8092
By default, the toggle button is inserted at the beginning of `.navbar-end`.

docs/modules/guide/pages/how-it-works.adoc

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,29 +52,34 @@ This JavaScript module handles:
5252
* *Click handling* - Switches between light and dark themes
5353
* *Persistence* - Saves preference to `localStorage`
5454
* *Icon updates* - Shows sun icon in dark mode, moon icon in light mode
55+
* *Root theme transition* - View Transitions when available; otherwise a short `html.adt-theme-animating` class so paint properties crossfade site-wide (skipped under `prefers-reduced-motion: reduce`)
5556

5657
Key functions:
5758

5859
[source,javascript]
5960
----
60-
// Apply theme and update button icon
61-
function applyTheme(theme) {
62-
if (theme === 'dark') {
63-
document.documentElement.classList.add('dark-theme');
64-
} else {
65-
document.documentElement.classList.remove('dark-theme');
61+
// Paint theme with platform transition (VT or animating-class fallback)
62+
function runThemePaint(updateFn, animate) {
63+
if (!animate || prefersReducedMotion()) {
64+
updateFn()
65+
return
66+
}
67+
if (typeof document.startViewTransition === 'function') {
68+
document.startViewTransition(updateFn)
69+
return
6670
}
67-
updateButtonIcon(theme);
68-
localStorage.setItem('antora-theme', theme);
71+
html.classList.add('adt-theme-animating')
72+
updateFn()
73+
setTimeout(() => html.classList.remove('adt-theme-animating'), 300)
6974
}
7075
71-
// Toggle between themes
7276
function toggleTheme() {
73-
const isDark = document.documentElement.classList.contains('dark-theme');
74-
applyTheme(isDark ? 'light' : 'dark');
77+
setMode(isDark() ? 'light' : 'dark', true)
7578
}
7679
----
7780

81+
See xref:guide:customization.adoc#theme-transition-contract[Theme transition contract] for extension guidance.
82+
7883
=== 4. site-extra.css (Dark Styles)
7984

8085
All dark mode styles use the selector prefix `html.dark-theme`:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "antora-dark-mode",
3-
"version": "1.3.1",
3+
"version": "1.4.0",
44
"description": "Dark mode supplemental UI for Antora — slot-based overlay, toggle, FOUC prevention, and CSS overrides.",
55
"keywords": [
66
"antora",

supplemental-ui/css/site-extra.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,38 @@ html.dark-theme .theme-toggle:hover {
526526
background-color: #2c2e33;
527527
}
528528

529+
/*
530+
* Root theme transition (platform contract)
531+
* -----------------------------------------
532+
* Toggle uses View Transitions when available (see site-dark-mode.js).
533+
* Fallback: temporary html.adt-theme-animating for ~300ms with a universal
534+
* paint transition so extension chrome that follows tokens / html.dark-theme
535+
* crossfades without per-widget transition rules.
536+
* Extensions should style via CSS variables or html.dark-theme — do not invent
537+
* their own light↔dark toggle animation.
538+
*/
539+
@media (prefers-reduced-motion: no-preference) {
540+
html.adt-theme-animating,
541+
html.adt-theme-animating *,
542+
html.adt-theme-animating *::before,
543+
html.adt-theme-animating *::after {
544+
transition:
545+
background-color 0.28s ease,
546+
color 0.28s ease,
547+
border-color 0.28s ease,
548+
fill 0.28s ease,
549+
stroke 0.28s ease,
550+
box-shadow 0.28s ease,
551+
filter 0.28s ease,
552+
opacity 0.28s ease !important;
553+
}
554+
555+
::view-transition-old(root),
556+
::view-transition-new(root) {
557+
animation-duration: 0.28s;
558+
}
559+
}
560+
529561
/* VCS repo logo (valentus-theme replaces Download) — true 1:1 square, compact */
530562
.navbar .vcs-repo-logo {
531563
padding: 0 0.5rem 0 0;

supplemental-ui/js/site-dark-mode.js

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,21 @@
44
const SYSTEM_SNAPSHOT_KEY = "antora-theme-system-snapshot";
55
const html = document.documentElement;
66
const darkThemeClass = "dark-theme";
7+
/** Temporary class while the class-based theme crossfade runs (VT fallback). */
8+
const themeAnimatingClass = "adt-theme-animating";
9+
const themeAnimMs = 300;
710
const systemMq = window.matchMedia("(prefers-color-scheme: dark)");
11+
const reducedMotionMq = window.matchMedia("(prefers-reduced-motion: reduce)");
12+
let themeAnimTimer;
813

914
function systemPreferenceLabel() {
1015
return systemMq.matches ? "dark" : "light";
1116
}
1217

18+
function prefersReducedMotion() {
19+
return reducedMotionMq.matches;
20+
}
21+
1322
function captureSystemSnapshot() {
1423
localStorage.setItem(SYSTEM_SNAPSHOT_KEY, systemPreferenceLabel());
1524
}
@@ -31,31 +40,58 @@
3140
return "system";
3241
}
3342

34-
function applyVisibleTheme() {
43+
/**
44+
* Platform theme paint: View Transitions when available, else a short universal
45+
* CSS transition via html.adt-theme-animating. Skipped when reduced motion.
46+
*/
47+
function runThemePaint(updateFn, animate) {
48+
if (!animate || prefersReducedMotion()) {
49+
updateFn();
50+
return;
51+
}
52+
if (typeof document.startViewTransition === "function") {
53+
document.startViewTransition(() => {
54+
updateFn();
55+
});
56+
return;
57+
}
58+
html.classList.add(themeAnimatingClass);
59+
// Ensure the browser applies the animating transition before the class flip.
60+
void html.offsetWidth;
61+
updateFn();
62+
window.clearTimeout(themeAnimTimer);
63+
themeAnimTimer = window.setTimeout(() => {
64+
html.classList.remove(themeAnimatingClass);
65+
}, themeAnimMs);
66+
}
67+
68+
function applyVisibleTheme(animate) {
3569
const mode = getMode();
3670
let useDark;
3771
if (mode === "system") {
3872
useDark = systemMq.matches;
3973
} else {
4074
useDark = mode === "dark";
4175
}
42-
if (useDark) {
43-
html.classList.add(darkThemeClass);
44-
} else {
45-
html.classList.remove(darkThemeClass);
46-
}
47-
updateToggleLabel();
76+
runThemePaint(() => {
77+
if (useDark) {
78+
html.classList.add(darkThemeClass);
79+
} else {
80+
html.classList.remove(darkThemeClass);
81+
}
82+
updateToggleLabel();
83+
}, Boolean(animate));
4884
}
4985

50-
function setMode(next) {
86+
function setMode(next, animate) {
5187
if (next === "system") {
5288
localStorage.setItem(MODE_KEY, "system");
5389
clearSystemSnapshot();
5490
} else {
5591
localStorage.setItem(MODE_KEY, next);
5692
captureSystemSnapshot();
5793
}
58-
applyVisibleTheme();
94+
applyVisibleTheme(animate !== false);
5995
}
6096

6197
function expireOverrideIfSystemChanged() {
@@ -67,22 +103,22 @@
67103
return;
68104
}
69105
if (snapshot !== systemPreferenceLabel()) {
70-
setMode("system");
106+
setMode("system", true);
71107
}
72108
}
73109

74110
function onSystemThemeChange() {
75111
const mode = getMode();
76112
if (mode === "system") {
77-
applyVisibleTheme();
113+
applyVisibleTheme(true);
78114
return;
79115
}
80116
expireOverrideIfSystemChanged();
81117
}
82118

83119
function applyInitialTheme() {
84120
expireOverrideIfSystemChanged();
85-
applyVisibleTheme();
121+
applyVisibleTheme(false);
86122
if (typeof systemMq.addEventListener === "function") {
87123
systemMq.addEventListener("change", onSystemThemeChange);
88124
} else {
@@ -115,7 +151,7 @@
115151
}
116152

117153
function toggleTheme() {
118-
setMode(isDark() ? "light" : "dark");
154+
setMode(isDark() ? "light" : "dark", true);
119155
const toggle = document.getElementById("theme-toggle");
120156
if (toggle) toggle.blur();
121157
}

ui-modules/packages/dark-mode/ui/css/site-extra.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,38 @@ html.dark-theme .theme-toggle:hover {
526526
background-color: #2c2e33;
527527
}
528528

529+
/*
530+
* Root theme transition (platform contract)
531+
* -----------------------------------------
532+
* Toggle uses View Transitions when available (see site-dark-mode.js).
533+
* Fallback: temporary html.adt-theme-animating for ~300ms with a universal
534+
* paint transition so extension chrome that follows tokens / html.dark-theme
535+
* crossfades without per-widget transition rules.
536+
* Extensions should style via CSS variables or html.dark-theme — do not invent
537+
* their own light↔dark toggle animation.
538+
*/
539+
@media (prefers-reduced-motion: no-preference) {
540+
html.adt-theme-animating,
541+
html.adt-theme-animating *,
542+
html.adt-theme-animating *::before,
543+
html.adt-theme-animating *::after {
544+
transition:
545+
background-color 0.28s ease,
546+
color 0.28s ease,
547+
border-color 0.28s ease,
548+
fill 0.28s ease,
549+
stroke 0.28s ease,
550+
box-shadow 0.28s ease,
551+
filter 0.28s ease,
552+
opacity 0.28s ease !important;
553+
}
554+
555+
::view-transition-old(root),
556+
::view-transition-new(root) {
557+
animation-duration: 0.28s;
558+
}
559+
}
560+
529561
/* VCS repo logo (valentus-theme replaces Download) — true 1:1 square, compact */
530562
.navbar .vcs-repo-logo {
531563
padding: 0 0.5rem 0 0;

0 commit comments

Comments
 (0)