fix(theme): 跟随系统切换改为瞬切,放弃 VT 动画

诊断确认:跟随系统与手动点击的 VT 流程在 JS/CSS 层完全等价(animationName、
--tt-r、bg 翻转、clip-path 全部正确),但视觉上跟随系统场景下圆形展开动画
不显示,仅瞬切。系统偏好变化是后台事件,VT 在此上下文下动画不可靠。

改用设置语义的瞬切:JS 侧新增 applyResolvedTheme(isDark) 入口(非翻转语义,
直接 add/remove dark class),Rust effect 改调 __applyResolvedTheme 传实际
明暗值,移除圆心计算逻辑。职责清晰:
- 手动点击 → __startThemeTransition → VT 圆形展开(用户手势,动画可靠)
- 跟随系统 → __applyResolvedTheme → 瞬切(后台同步语义)
This commit is contained in:
xfy 2026-07-03 13:42:17 +08:00
parent eb3087edf4
commit cb77d28a68
3 changed files with 27 additions and 33 deletions

View File

@ -1,13 +1,15 @@
import { initPostContent } from './post-content'; import { initPostContent } from './post-content';
import { startThemeTransition } from './theme-transition'; import { applyResolvedTheme, startThemeTransition } from './theme-transition';
import './style.css'; import './style.css';
declare global { declare global {
interface Window { interface Window {
__initPostContent: (selector: string) => void; __initPostContent: (selector: string) => void;
__startThemeTransition: (x: number, y: number) => void; __startThemeTransition: (x: number, y: number) => void;
__applyResolvedTheme: (isDark: boolean) => void;
} }
} }
window.__initPostContent = initPostContent; window.__initPostContent = initPostContent;
window.__startThemeTransition = startThemeTransition; window.__startThemeTransition = startThemeTransition;
window.__applyResolvedTheme = applyResolvedTheme;

View File

@ -40,6 +40,18 @@ function applyDarkClass(isDark: boolean): void {
} }
} }
/**
* <html> dark class
*
* View Transitions
*
* startThemeTransition VT class
* startThemeTransition
*/
export function applyResolvedTheme(isDark: boolean): void {
applyDarkClass(isDark);
}
export function startThemeTransition(x: number, y: number): void { export function startThemeTransition(x: number, y: number): void {
const html = document.documentElement; const html = document.documentElement;
const isDark = !html.classList.contains('dark'); const isDark = !html.classList.contains('dark');

View File

@ -206,9 +206,12 @@ pub fn use_theme_provider() -> Signal<Theme> {
// //
// 仅在 System 模式theme == System且 resolved 实际翻转时触发Light/Dark 显式 // 仅在 System 模式theme == System且 resolved 实际翻转时触发Light/Dark 显式
// 模式下 resolved 由 theme 决定DOM 已由 ThemeToggle::onclick 全权处理effect // 模式下 resolved 由 theme 决定DOM 已由 ThemeToggle::onclick 全权处理effect
// 不介入,避免与手动点击的 VT 动画重复触发。以主题按钮位置为圆心复用同一条 JS // 不介入,避免与手动点击的 VT 动画重复触发。
// 路径__startThemeTransition 从 DOM 现状推导目标明暗),让自动切换与手动点击 //
// 的动画起点一致;屏幕中心圆心会让圆形展开近似「整体覆盖」,被误看成淡入淡出。 // 采用无动画的瞬切__applyResolvedTheme设置语义而非 VT 圆形展开:系统偏好
// 变化是后台事件,实测 View Transitions 在此上下文下动画不显示VT 流程虽正常
// 完成、CSS 也正确挂上 tt-expand但视觉上仅瞬切故跟随系统走瞬切更可靠
// 手动点击仍走 __startThemeTransition 保留圆形展开动画。
// //
// 首次挂载自然跳过effect 通过 prev 信号记录上一次 resolved初次运行 prev 为 // 首次挂载自然跳过effect 通过 prev 信号记录上一次 resolved初次运行 prev 为
// None此时 DOM 已由 ThemePreload 脚本设为正确状态,直接对齐 prev 后返回。 // None此时 DOM 已由 ThemePreload 脚本设为正确状态,直接对齐 prev 后返回。
@ -221,7 +224,7 @@ pub fn use_theme_provider() -> Signal<Theme> {
return; return;
} }
let current = resolved(); let current = resolved();
// 首次运行DOM 已由 ThemePreload 设好,仅记录基线,不触发动画 // 首次运行DOM 已由 ThemePreload 设好,仅记录基线,不触发同步
let Some(prev) = prev_resolved() else { let Some(prev) = prev_resolved() else {
prev_resolved.set(Some(current)); prev_resolved.set(Some(current));
return; return;
@ -231,40 +234,17 @@ pub fn use_theme_provider() -> Signal<Theme> {
return; return;
} }
prev_resolved.set(Some(current)); prev_resolved.set(Some(current));
// resolved 翻转 → 触发 VT 圆形展开动画 // resolved 翻转 → 瞬切同步 dark class设置语义非翻转
let Some(window) = web_sys::window() else { let Some(window) = web_sys::window() else {
return; return;
}; };
// 圆心优先取主题切换按钮(.theme-toggle的中心与手动点击的动画起点一致 let key = "__applyResolvedTheme".into();
// 按钮不在 DOM如未渲染导航栏的页面时回退到视口中心。
use wasm_bindgen::JsCast;
let (x, y) = window
.document()
.and_then(|doc| doc.query_selector(".theme-toggle").ok().flatten())
.map(|el| {
let r = el.unchecked_into::<web_sys::Element>().get_bounding_client_rect();
(r.x() + r.width() / 2.0, r.y() + r.height() / 2.0)
})
.unwrap_or_else(|| {
let w = window
.inner_width()
.ok()
.and_then(|v| v.as_f64())
.map(|v| v / 2.0)
.unwrap_or(0.0);
let h = window
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.map(|v| v / 2.0)
.unwrap_or(0.0);
(w, h)
});
let key = "__startThemeTransition".into();
if let Ok(fn_val) = js_sys::Reflect::get(&window, &key) { if let Ok(fn_val) = js_sys::Reflect::get(&window, &key) {
if !fn_val.is_undefined() && !fn_val.is_null() { if !fn_val.is_undefined() && !fn_val.is_null() {
use wasm_bindgen::JsCast;
let fn_obj = fn_val.unchecked_into::<js_sys::Function>(); let fn_obj = fn_val.unchecked_into::<js_sys::Function>();
let _ = fn_obj.call2(&window, &x.into(), &y.into()); let is_dark = current == ResolvedTheme::Dark;
let _ = fn_obj.call1(&window, &is_dark.into());
} }
} }
}); });