refactor(theme): use pure CSS View Transitions without WAAPI or style injection

Adopt a fully CSS-driven strategy for the theme transition:
- Use CSS variables (--tt-x, --tt-y, --tt-r) injected onto documentElement
- Define static @keyframes in style.css using these variables
- Key logic: the Dark layer is ALWAYS placed on top (z-index: 2).
  - Light -> Dark: NEW layer is Dark, so NEW expands from circle(0) to cover OLD.
  - Dark -> Light: OLD layer is Dark, so OLD shrinks to circle(0) to reveal NEW.

This completely eliminates reliance on Web Animations API (which had bugs
with clip-path when animation:none is set) and eliminates dynamic <style>
injection (which caused timing gaps and style residue).
The result is perfectly symmetrical and bug-free in both directions.
This commit is contained in:
xfy 2026-06-26 18:14:29 +08:00
parent 76eedebf05
commit 0ff24888ce
3 changed files with 71 additions and 61 deletions

View File

@ -1,13 +1,53 @@
/* ========== 主题切换圆形展开(View Transitions API) ========== */
/* 不用 animation: none Chrome VT 伪元素设置 animation:none
会阻止 WAAPI clip-path 动画生效(亮方向完全看不到动画)
改为用 opacity:1 !important 锁定两层不透明度:UA fade-in/out 动画
技术上还在运行, !important 阻止它修改 opacity,视觉上等于无动画 */
/* 取消所有 UA 默认的动画和颜色混合 */
::view-transition-old(root),
::view-transition-new(root) {
opacity: 1 !important;
mix-blend-mode: normal !important;
animation: none;
mix-blend-mode: normal;
display: block;
}
/* =========================================================================
设计策略
无论方向我们始终让暗色层在上面并对其使用 clip-path
- 亮->NEW 是暗色放在上层从小圆扩展
- 暗->OLD 是暗色放在上层从大圆收缩
这样就能保证无论哪个方向都是暗色吞噬亮色亮色穿透暗色
========================================================================= */
/* 默认情况从暗到亮 (Dark -> Light)
此时 DOM 上的 .dark 已经被移除目标是亮色(NEW)
策略把暗色旧图(OLD)放在上层缩小它的 clip-path揭开底部的亮色新图(NEW) */
::view-transition-old(root) {
z-index: 2;
animation: tt-shrink 0.4s ease-out forwards;
}
::view-transition-new(root) {
z-index: 1;
}
/* 切换到暗色情况从亮到暗 (Light -> Dark)
此时 DOM 上已加上 .dark目标是暗色(NEW)
策略把暗色新图(NEW)放在上层放大它的 clip-path覆盖底部的亮色旧图(OLD) */
.dark::view-transition-old(root) {
z-index: 1;
animation: none;
}
.dark::view-transition-new(root) {
z-index: 2;
animation: tt-expand 0.4s ease-out forwards;
}
/* CSS 变量由 JS 注入到 html style 中 */
@keyframes tt-expand {
from { clip-path: circle(0px at var(--tt-x) var(--tt-y)); }
to { clip-path: circle(var(--tt-r) at var(--tt-x) var(--tt-y)); }
}
@keyframes tt-shrink {
from { clip-path: circle(var(--tt-r) at var(--tt-x) var(--tt-y)); }
to { clip-path: circle(0px at var(--tt-x) var(--tt-y)); }
}
/* View Transition 期间强制禁用所有 CSS 过渡,

View File

@ -39,7 +39,7 @@ describe('startThemeTransition', () => {
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('主路径:有 startViewTransition 时调用它,callback 切换 dark class', async () => {
it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => {
const cbRef: { cb: (() => void) | null } = { cb: null };
const readyP = Promise.resolve();
const finishedP = Promise.resolve();
@ -53,14 +53,15 @@ describe('startThemeTransition', () => {
writable: true,
});
// Mock animate on documentElement (happy-dom 不支持 pseudoElement)
const animateSpy = vi.fn(() => ({ finished: Promise.resolve() }));
document.documentElement.animate = animateSpy as unknown as typeof document.documentElement.animate;
window.__startThemeTransition(100, 200);
expect(startVT).toHaveBeenCalledTimes(1);
// CSS 变量应注入
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('100px');
expect(document.documentElement.style.getPropertyValue('--tt-y')).toBe('200px');
expect(document.documentElement.style.getPropertyValue('--tt-r')).toMatch(/^\d+(\.\d+)?px$/);
// is-theme-transitioning 应在 VT 之前添加
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
@ -68,23 +69,13 @@ describe('startThemeTransition', () => {
cbRef.cb!();
expect(document.documentElement.classList.contains('dark')).toBe(true);
// ready 后通过 Web Animations API 控制动画
await readyP;
await Promise.resolve();
// animate 应被调用一次:clip-path 展开 on new
expect(animateSpy).toHaveBeenCalledTimes(1);
// 调用应包含 clipPath + pseudoElement
const calls = animateSpy.mock.calls as unknown[][];
const newCall = calls[0];
expect(newCall[0]).toHaveProperty('clipPath');
expect(newCall[1]).toHaveProperty('pseudoElement', '::view-transition-new(root)');
// finished 后移除 is-theme-transitioning
// finished 后移除 is-theme-transitioning 和 CSS 变量
await finishedP;
await Promise.resolve();
// happy-dom microtask 可能需要额外 tick
await new Promise((r) => setTimeout(r, 0));
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(false);
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('');
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});

View File

@ -1,21 +1,13 @@
/**
* (View Transitions API)
*
* , (x,y)
*
* 实现方式:使用 Web Animations API pseudoElement , vt.ready
* ::view-transition-old/new(root) <style>
* + @keyframes ,:
* - Web Animation CSS ( UA fade-in/out),
* , !important ;
* - Animation , <style>
* ;
* - prevStyleEl
*
* mix-blend-mode: normal style.css ()
* CSS transition VT .is-theme-transitioning class
*
* 降级: startViewTransition prefers-reduced-motion dark class
* CSS CSS
* 核心策略:始终让"暗色层", clip-path "亮色层"
* - -> : NEW (),(`tt-expand`) OLD
* - -> : OLD (),(`tt-shrink`) NEW
*
* WAAPI <style>,DOM
* API bug, VT
*/
function prefersReducedMotion(): boolean {
@ -53,8 +45,6 @@ function applyDarkClass(isDark: boolean): void {
export function startThemeTransition(x: number, y: number): void {
const html = document.documentElement;
// 目标主题从 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') });
@ -69,6 +59,11 @@ export function startThemeTransition(x: number, y: number): void {
const maxR = maxCornerDistance(x, y);
// 注入动画需要的 CSS 变量
html.style.setProperty('--tt-x', `${x}px`);
html.style.setProperty('--tt-y', `${y}px`);
html.style.setProperty('--tt-r', `${maxR}px`);
// 禁用所有 CSS transition,确保 VT 截图是最终颜色
html.classList.add('is-theme-transitioning');
@ -81,27 +76,7 @@ export function startThemeTransition(x: number, y: number): void {
});
vt.ready
.then(() => {
console.log('[tt] ready, animating clip-path via WAAPI');
// CSS 已通过 animation:none + opacity:1 (!important) 锁定两层:
// OLD 保持完全可见(暗色底图),NEW 保持完全不透明。
// WAAPI 只需控制 NEW 的 clip-path 实现圆形展开。
// script-created Animation 优先级高于 CSS animation(已被 none 禁用),
// 只添加 clip-path 动画,不与 CSS 冲突。
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxR}px at ${x}px ${y}px)`,
],
},
{
duration: 400,
easing: 'ease-out',
pseudoElement: '::view-transition-new(root)',
},
);
})
.then(() => console.log('[tt] ready OK'))
.catch(() => {});
vt.finished
@ -109,5 +84,9 @@ export function startThemeTransition(x: number, y: number): void {
.catch((e) => console.log('[tt] VT REJECT:', e))
.finally(() => {
html.classList.remove('is-theme-transitioning');
// 清理 CSS 变量
html.style.removeProperty('--tt-x');
html.style.removeProperty('--tt-y');
html.style.removeProperty('--tt-r');
});
}