fix(theme): fix dark→light transition animation invisible

The static CSS rule 'animation: none' on ::view-transition-new(root)
caused the new (light) screenshot to be fully visible before the JS-
injected clip-path @keyframes kicked in. This meant the light layer
was already covering the dark layer, making the expanding circle
invisible.

Fix: move all VT pseudo-element styles (animation, mix-blend-mode)
into the dynamically injected <style> block in vt.ready, so they
apply atomically with the clip-path keyframes.

Also:
- Add transition:none !important during VT to prevent body's
  background-color 0.3s transition from producing a stale capture.
- Restore Dioxus theme.set() that was disabled for debugging, so
  the toggle icon and localStorage persist correctly.
- Use !important on injected animation to reliably override any
  UA default VT animations.
This commit is contained in:
xfy 2026-06-26 17:50:45 +08:00
parent c1781d7831
commit ba9db2d75a
4 changed files with 132 additions and 128 deletions

View File

@ -1,34 +1,12 @@
/* ========== 主题切换圆形展开(View Transitions API) ========== */ /* ========== 主题切换圆形展开(View Transitions API) ========== */
/* VT 回调期间临时冻结所有 CSS 过渡 /* View Transition 期间强制禁用所有 CSS 过渡确保截图捕获的是最终状态
原因:body transition: background-color 0.3s(input.css), 不要在这里设置 ::view-transition-old/new animation:
toggle dark class 后真实渲染的背景色要 0.3s 才过渡到新主题 静态 animation: none 会让 new 在注入 @keyframes 之前完全不透明地覆盖 old,
VT toggle 后的下一帧(~16ms) NEW 快照此时背景色才走了 ~5%, 导致暗亮方向看不到动画所有 VT 伪元素样式由 JS vt.ready 中统一注入 */
新旧两张快照几乎一样,圆形展开就看不出颜色差 html.is-theme-transitioning,
冻结过渡让 NEW 快照立即拿到新主题配色,圆形才有可见的颜色对比 */ html.is-theme-transitioning *,
html.vt-freeze, html.is-theme-transitioning *::before,
html.vt-freeze * { html.is-theme-transitioning *::after {
transition: none !important; transition: none !important;
} }
/* 取消默认交叉淡入淡出,由 clip-path: circle() 接管新快照的展开。 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
/* --tt-x / --tt-y / --tt-max-r 由 JS 在 html 上设。 */
animation: tt-reveal 0.4s ease-out;
}
@keyframes tt-reveal {
from {
clip-path: circle(0px at var(--tt-x, 0px) var(--tt-y, 0px));
}
to {
clip-path: circle(var(--tt-max-r, 100vmax) at var(--tt-x, 0px) var(--tt-y, 0px));
}
}

View File

@ -2,7 +2,10 @@
* theme-transition * theme-transition
* *
* happy-dom document.startViewTransition,( dark class) * happy-dom document.startViewTransition,( dark class)
* mock startViewTransition 验证:调用它 CSS * mock startViewTransition 验证:调用它 CSS
*
* 注意:startThemeTransition (x, y),(/) DOM dark class
* (),
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import './index'; import './index';
@ -10,43 +13,43 @@ import './index';
describe('startThemeTransition', () => { describe('startThemeTransition', () => {
beforeEach(() => { beforeEach(() => {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('vt-freeze');
document.documentElement.style.cssText = ''; document.documentElement.style.cssText = '';
document.head.querySelectorAll('style').forEach((s) => {
if (s.textContent?.includes('tt-')) s.remove();
});
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
afterEach(() => { afterEach(() => {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('vt-freeze');
document.documentElement.style.cssText = ''; document.documentElement.style.cssText = '';
document.head.querySelectorAll('style').forEach((s) => {
if (s.textContent?.includes('tt-')) s.remove();
});
}); });
it('降级:无 startViewTransition 时直接 toggle dark class(切到 dark)', () => { it('降级:无 startViewTransition 时,亮→暗(无 dark class 时 add)', () => {
expect(document.documentElement.classList.contains('dark')).toBe(false); expect(document.documentElement.classList.contains('dark')).toBe(false);
window.__startThemeTransition(100, 200, true); window.__startThemeTransition(100, 200);
expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.classList.contains('dark')).toBe(true);
}); });
it('降级:切到 light 时移除 dark class', () => { it('降级:无 startViewTransition 时,暗→亮(有 dark class 时 remove)', () => {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
window.__startThemeTransition(100, 200, false); window.__startThemeTransition(100, 200);
expect(document.documentElement.classList.contains('dark')).toBe(false); expect(document.documentElement.classList.contains('dark')).toBe(false);
}); });
it('降级时不设 CSS 变量(--tt-x/y/max-r 仅主路径用)', () => { it('主路径:有 startViewTransition 时调用它,并注入带 keyframes 的 style', async () => {
window.__startThemeTransition(100, 200, true);
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('');
});
it('主路径:有 startViewTransition 时调用它并设 CSS 变量', () => {
const cbRef: { cb: (() => void) | null } = { cb: null }; const cbRef: { cb: (() => void) | null } = { cb: null };
const readyP = Promise.resolve();
const finishedP = Promise.resolve();
const startVT = vi.fn((cb: () => void) => { const startVT = vi.fn((cb: () => void) => {
cbRef.cb = cb; cbRef.cb = cb;
return { finished: Promise.resolve(), skipTransition: () => {} }; return { ready: readyP, finished: finishedP, skipTransition: () => {} };
}); });
Object.defineProperty(document, 'startViewTransition', { Object.defineProperty(document, 'startViewTransition', {
value: startVT, value: startVT,
@ -54,52 +57,28 @@ describe('startThemeTransition', () => {
writable: true, writable: true,
}); });
window.__startThemeTransition(100, 200, true); window.__startThemeTransition(100, 200);
// 调了 startViewTransition
expect(startVT).toHaveBeenCalledTimes(1); expect(startVT).toHaveBeenCalledTimes(1);
// 设了圆心与半径变量 // callback 里根据 DOM 现状(无 dark)切到 dark
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('100px');
expect(document.documentElement.style.getPropertyValue('--tt-y')).toBe('200px');
// max-r 是个正数 px 值
expect(document.documentElement.style.getPropertyValue('--tt-max-r')).toMatch(/^\d+(\.\d+)?px$/);
// 回调里 toggle dark class(模拟浏览器截图前)
expect(document.documentElement.classList.contains('dark')).toBe(false);
cbRef.cb!(); cbRef.cb!();
expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.classList.contains('dark')).toBe(true);
// 回调里同时冻结 CSS 过渡,让 NEW 快照立即反映新主题配色
expect(document.documentElement.classList.contains('vt-freeze')).toBe(true);
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; // ready 后注入带 keyframes 的 style
}); await readyP;
it('主路径:vt.finished 结束后移除 vt-freeze 并复位重入标志', async () => {
const finishedPromise = Promise.resolve();
const startVT = vi.fn((cb: () => void) => {
cb();
return { finished: finishedPromise, skipTransition: () => {} };
});
Object.defineProperty(document, 'startViewTransition', {
value: startVT,
configurable: true,
writable: true,
});
window.__startThemeTransition(0, 0, true);
// 回调执行后 freeze 已加
expect(document.documentElement.classList.contains('vt-freeze')).toBe(true);
await finishedPromise;
// 微任务:.finally 在 finished resolve 后于微任务阶段执行,等一轮
await Promise.resolve(); await Promise.resolve();
expect(document.documentElement.classList.contains('vt-freeze')).toBe(false); const style = document.head.querySelector('style');
expect(style).not.toBeNull();
expect(style?.textContent).toContain('circle(0px at 100px 200px)');
expect(style?.textContent).toMatch(/circle\(\d+(\.\d+)?px at 100px 200px\)/);
expect(style?.textContent).toContain('::view-transition-old(root)');
expect(style?.textContent).toContain('::view-transition-new(root)');
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
}); });
it('reduced-motion:即使有 startViewTransition 也走降级(瞬切)', () => { it('reduced-motion:即使有 startViewTransition 也走降级(瞬切)', () => {
const startVT = vi.fn(() => ({ finished: Promise.resolve(), skipTransition: () => {} })); const startVT = vi.fn(() => ({ ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} }));
Object.defineProperty(document, 'startViewTransition', { Object.defineProperty(document, 'startViewTransition', {
value: startVT, value: startVT,
configurable: true, configurable: true,
@ -116,7 +95,7 @@ describe('startThemeTransition', () => {
dispatchEvent: () => false, dispatchEvent: () => false,
}))); })));
window.__startThemeTransition(0, 0, true); window.__startThemeTransition(0, 0);
expect(startVT).not.toHaveBeenCalled(); expect(startVT).not.toHaveBeenCalled();
expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.classList.contains('dark')).toBe(true);

View File

@ -2,14 +2,21 @@
* (View Transitions API) * (View Transitions API)
* *
* , (x,y) * , (x,y)
* 流程: CSS / startViewTransition( toggle dark class) *
* CSS @keyframes clip-path: circle() ::view-transition-new(root) * 实现方式:启动 VT , vt.ready <style>,
* @keyframes ::view-transition-new(root) clip-path: circle()
* CSS () element.animate( bug)
*
* 关键设计:所有 VT (animation/mix-blend-mode) JS
* , CSS CSS animation: none
* ::view-transition-new @keyframes old,
* ( new ,clip-path 0
* )
* *
* 降级: startViewTransition prefers-reduced-motion dark class * 降级: startViewTransition prefers-reduced-motion dark class
* 重入保护:动画进行中(transitioning=true)
*/ */
let transitioning = false; let prevStyleEl: HTMLStyleElement | null = null;
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
return ( return (
@ -18,7 +25,6 @@ function prefersReducedMotion(): boolean {
); );
} }
/** 视口四角到 (x,y) 的最大欧氏距离,作为圆形展开半径(保证完全覆盖视口)。 */
function maxCornerDistance(x: number, y: number): number { function maxCornerDistance(x: number, y: number): number {
const w = window.innerWidth; const w = window.innerWidth;
const h = window.innerHeight; const h = window.innerHeight;
@ -45,39 +51,68 @@ function applyDarkClass(isDark: boolean): void {
} }
} }
export function startThemeTransition(x: number, y: number, isDark: boolean): void { export function startThemeTransition(x: number, y: number): void {
if (transitioning) return; const html = document.documentElement;
transitioning = true; // 目标主题从 DOM 现状推导,不依赖外部传入——避免与 Rust Signal 状态不同步
// 导致方向错乱(isDark 传反而 toggle 成 no-op,新旧快照一样看不到动画)。
const isDark = !html.classList.contains('dark');
console.log('[tt] ENTER', { x, y, isDark, domHasDark: html.classList.contains('dark') });
const hasVT = typeof document.startViewTransition === 'function'; const hasVT = typeof document.startViewTransition === 'function';
const reduced = prefersReducedMotion(); const reduced = prefersReducedMotion();
// 降级路径:瞬切。body 的 .3s 背景过渡仍给柔和淡入。
if (!hasVT || reduced) { if (!hasVT || reduced) {
console.log('[tt] DEGRADE');
applyDarkClass(isDark); applyDarkClass(isDark);
transitioning = false;
return; return;
} }
// 主路径:设圆心与半径,启动 VT。 const maxR = maxCornerDistance(x, y);
const html = document.documentElement;
html.style.setProperty('--tt-x', `${x}px`); // 添加临时类,用于在 CSS 中禁用 transition确保截图捕获最终状态
html.style.setProperty('--tt-y', `${y}px`); html.classList.add('is-theme-transitioning');
html.style.setProperty('--tt-max-r', `${maxCornerDistance(x, y)}px`);
// VT 回调里:先冻结所有 CSS 过渡(让 NEW 快照立即拿到新主题配色,
// 不被 body 的 background-color .3s 过渡拖慢),再 toggle dark class。
const vt = document.startViewTransition(() => { const vt = document.startViewTransition(() => {
html.classList.add('vt-freeze'); console.log('[tt] CALLBACK set dark=', isDark);
applyDarkClass(isDark); applyDarkClass(isDark);
}); });
// vt.finished 在 skipTransition 或页面跳转时会 reject(属预期),用 .catch 吞掉 vt.ready
// 以避免 unhandled rejection 刷控制台;无论 resolve/reject 都复位重入标志与冻结。 .then(() => {
// 移除上一次注入的 style,避免堆积。
if (prevStyleEl) {
prevStyleEl.remove();
}
// 注入硬编码坐标的 @keyframes + VT 伪元素样式。
// 关键:old 和 new 的 animation/mix-blend-mode 必须在这里设置,
// 不能预设在静态 CSS 里,否则会出现 new 在动画注入前完全可见的闪烁。
const name = `tt-${Date.now()}`;
const style = document.createElement('style');
style.textContent = `
@keyframes ${name} {
from { clip-path: circle(0px at ${x}px ${y}px); }
to { clip-path: circle(${maxR}px at ${x}px ${y}px); }
}
::view-transition-old(root) {
animation: none !important;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: ${name} 0.4s ease-out !important;
mix-blend-mode: normal;
}
`;
document.head.appendChild(style);
prevStyleEl = style;
console.log('[tt] ready, injected keyframes:', name);
})
.catch(() => {});
vt.finished vt.finished
.catch(() => {}) .then(() => console.log('[tt] VT finished OK'))
.catch((e) => console.log('[tt] VT REJECT:', e))
.finally(() => { .finally(() => {
html.classList.remove('vt-freeze'); // 动画完成后移除临时类
transitioning = false; html.classList.remove('is-theme-transitioning');
}); });
} }

View File

@ -92,28 +92,19 @@ fn detect_initial_theme() -> Theme {
/// 提供主题上下文的 Hook。 /// 提供主题上下文的 Hook。
/// ///
/// 初始化时按 SSR Cookie → WASM localStorage → 系统偏好的顺序检测主题; /// 初始化时按 SSR Cookie → WASM localStorage → 系统偏好的顺序检测主题;
/// 主题变化时同步更新 HTML 根元素的 `dark` class 与 localStorage。 /// 主题变化时将值持久化到 localStorage。
///
/// `<html>` 的 `dark` class 不在此处管理。WASM 端由 `ThemeToggle` 的 onclick
/// 通过 `yggdrasil-core.js` 的圆形展开动画在 View Transition 回调里同步 toggle。
/// 初始 class 由 `ThemePreload` 首屏脚本设置,避免闪烁。
pub fn use_theme_provider() -> Signal<Theme> { pub fn use_theme_provider() -> Signal<Theme> {
let theme = use_signal(detect_initial_theme); let theme = use_signal(detect_initial_theme);
use_effect(move || { use_effect(move || {
let current = theme();
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let current = theme();
if let Some(window) = web_sys::window() { if let Some(window) = web_sys::window() {
// 同步 HTML 根元素的 dark class用于 Tailwind dark mode。
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
match current {
Theme::Dark => {
let _ = html.class_list().add_1("dark");
}
Theme::Light => {
let _ = html.class_list().remove_1("dark");
}
}
}
}
// 将当前主题持久化到 localStorage。 // 将当前主题持久化到 localStorage。
if let Ok(Some(storage)) = window.local_storage() { if let Ok(Some(storage)) = window.local_storage() {
let theme_str = match current { let theme_str = match current {
@ -124,6 +115,8 @@ pub fn use_theme_provider() -> Signal<Theme> {
} }
} }
} }
// 避免 unused 警告:非 wasm 构建下 current 未被读取。
let _ = current;
}); });
use_context_provider(|| theme); use_context_provider(|| theme);
@ -160,12 +153,14 @@ pub fn ThemePreload() -> Element {
} }
/// 主题切换按钮组件。 /// 主题切换按钮组件。
// evt 仅在 wasm32 的圆形展开动画里使用(取点击坐标),服务端构建剥离, // evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。
// 故允许非 wasm 构建下的 unused_variables(与 Write 组件同模式)。
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
#[component] #[component]
pub fn ThemeToggle() -> Element { pub fn ThemeToggle() -> Element {
let mut theme = use_theme(); let mut theme = use_theme();
// generation:每次点击递增。延迟回调检查自己的 gen 是否最新,过期则跳过 set。
// 解决连续点击时多个 spawn_local 堆积导致的状态错乱。
let mut click_gen = use_signal(|| 0u32);
rsx! { rsx! {
button { button {
@ -176,18 +171,35 @@ pub fn ThemeToggle() -> Element {
let next = theme().toggle(); let next = theme().toggle();
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let is_dark = next == Theme::Dark;
let coords = evt.client_coordinates(); let coords = evt.client_coordinates();
// 兜底:若 yggdrasil-core.js 尚未加载完(__startThemeTransition 未定义), let x = coords.x;
// 跳过动画(后续 use_effect 仍会同步 dark class 与 localStorage)。 let y = coords.y;
// JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。
let _ = js_sys::eval(&format!( let _ = js_sys::eval(&format!(
"if (window.__startThemeTransition) \ "if (window.__startThemeTransition) \
window.__startThemeTransition({x}, {y}, {is_dark})", window.__startThemeTransition({x}, {y});",
x = coords.x, x = x,
y = coords.y, y = y,
)); ));
// theme.set 推迟到动画结束:其触发的 Dioxus 微任务重渲染会打断 VT。
// gen 确保连续点击只有最新回调 set。JS 已自治切换 dark class,
// 动画完成后再更新 Dioxus 状态,避免组件重渲染可能带来的干扰
let gen = click_gen() + 1;
click_gen.set(gen);
let mut theme_clone = theme;
let mut gen_clone = click_gen;
wasm_bindgen_futures::spawn_local(async move {
crate::utils::time::sleep_ms(450).await;
if gen_clone() == gen {
theme_clone.set(next);
}
});
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
theme.set(next);
} }
theme.set(next);
}, },
if theme() == Theme::Dark { if theme() == Theme::Dark {
svg { svg {