refactor(theme): switch VT animation from CSS injection to Web Animations API

Replace dynamic <style> injection (@keyframes + pseudo-element rules)
with document.documentElement.animate({ pseudoElement }) calls.

Previous approach accumulated <style> elements across transitions,
causing subsequent dark→light animations to be invisible. The Web
Animations API:
- Creates per-call Animation objects with no DOM residue
- Has higher composite priority than CSS animations, naturally
  overriding UA default fade-in/out without !important
- Eliminates style element lifecycle management entirely

Static CSS now only sets mix-blend-mode: normal (not animatable via
WAAPI) and the is-theme-transitioning transition suppression.
This commit is contained in:
xfy 2026-06-26 18:02:26 +08:00
parent b542952619
commit f0ea1a294f
3 changed files with 78 additions and 59 deletions

View File

@ -1,9 +1,15 @@
/* ========== 主题切换圆形展开(View Transitions API) ========== */
/* View Transition 期间强制禁用所有 CSS 过渡确保截图捕获的是最终状态
不要在这里设置 ::view-transition-old/new animation:
静态 animation: none 会让 new 在注入 @keyframes 之前完全不透明地覆盖 old,
导致暗亮方向看不到动画所有 VT 伪元素样式由 JS vt.ready 中统一注入 */
/* mix-blend-mode 不可通过 Web Animations API 设置,只能用 CSS
UA 默认 plus-lighter 会导致新旧层颜色叠加发亮,需覆盖为 normal
author 样式天然覆盖 UA 样式,无需 !important */
::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
}
/* View Transition 期间强制禁用所有 CSS 过渡,
确保 VT 回调中 toggle dark class 后截图捕获的是最终颜色 */
html.is-theme-transitioning,
html.is-theme-transitioning *,
html.is-theme-transitioning *::before,

View File

@ -13,18 +13,14 @@ import './index';
describe('startThemeTransition', () => {
beforeEach(() => {
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('is-theme-transitioning');
document.documentElement.style.cssText = '';
document.head.querySelectorAll('style').forEach((s) => {
if (s.textContent?.includes('tt-')) s.remove();
});
vi.restoreAllMocks();
});
afterEach(() => {
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('is-theme-transitioning');
document.documentElement.style.cssText = '';
document.head.querySelectorAll('style').forEach((s) => {
if (s.textContent?.includes('tt-')) s.remove();
});
});
it('降级:无 startViewTransition 时,亮→暗(无 dark class 时 add)', () => {
@ -43,7 +39,7 @@ describe('startThemeTransition', () => {
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('主路径:有 startViewTransition 时调用它,并注入带 keyframes 的 style', async () => {
it('主路径:有 startViewTransition 时调用它,callback 切换 dark class', async () => {
const cbRef: { cb: (() => void) | null } = { cb: null };
const readyP = Promise.resolve();
const finishedP = Promise.resolve();
@ -57,25 +53,38 @@ 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);
// 样式预注入:在 startViewTransition 之前就已注入 <style>
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)');
// is-theme-transitioning 应在 VT 之前添加
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
// callback 里根据 DOM 现状(无 dark)切到 dark
cbRef.cb!();
expect(document.documentElement.classList.contains('dark')).toBe(true);
// ready 后无需注入(已预注入)
// ready 后通过 Web Animations API 控制动画
await readyP;
await Promise.resolve();
// animate 应被调用两次:一次 old(opacity),一次 new(clipPath + opacity)
expect(animateSpy).toHaveBeenCalledTimes(2);
// 第二次调用(new)应包含 clipPath
const calls = animateSpy.mock.calls as unknown[][];
const newCall = calls[1];
expect(newCall[0]).toHaveProperty('clipPath');
expect(newCall[1]).toHaveProperty('pseudoElement', '::view-transition-new(root)');
// finished 后移除 is-theme-transitioning
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);
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});

View File

@ -3,20 +3,21 @@
*
* , (x,y)
*
* :
* 1. VT ** startViewTransition ** <head>,
* ::view-transition-old/new ,
* ( vt.ready , NEW )
* 2. VT toggle dark class , getComputedStyle
* , NEW , CSS transition
* 3. html.is-theme-transitioning class CSS transition,
* body background-color 0.3s
* 实现方式:使用 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
*/
let prevStyleEl: HTMLStyleElement | null = null;
function prefersReducedMotion(): boolean {
return (
!!window.matchMedia &&
@ -68,46 +69,49 @@ export function startThemeTransition(x: number, y: number): void {
const maxR = maxCornerDistance(x, y);
// ── 1. 预注入 VT 伪元素样式(在 startViewTransition 之前) ──
// 移除上一次注入的 style,避免堆积。
if (prevStyleEl) {
prevStyleEl.remove();
}
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;
// ── 2. 禁用所有 CSS transition,确保截图是最终颜色 ──
// 禁用所有 CSS transition,确保 VT 截图是最终颜色
html.classList.add('is-theme-transitioning');
// ── 3. 启动 View Transition ──
const vt = document.startViewTransition(() => {
console.log('[tt] CALLBACK set dark=', isDark);
applyDarkClass(isDark);
// 强制同步样式重算:确保浏览器截取 NEW 快照前,
// body 的 background-color 已经解析为目标值。
// 强制同步样式重算:确保 body 的 background-color 解析为目标值
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
getComputedStyle(document.body).backgroundColor;
});
// 样式已预注入,vt.ready 不再需要处理
vt.ready
.then(() => console.log('[tt] ready OK'))
.then(() => {
console.log('[tt] ready, animating via WAAPI');
// ── Web Animations API ──
// script-created 动画优先级高于 CSS 动画(含 UA 默认 fade-in/out),
// 天然覆盖,无需 !important,无残留样式问题。
// OLD: 覆盖 UA 默认的 fade-out,保持完全可见作为底图
document.documentElement.animate(
{ opacity: [1, 1] },
{
duration: 400,
pseudoElement: '::view-transition-old(root)',
},
);
// NEW: 覆盖 UA 默认的 fade-in,改为 clip-path 圆形展开
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxR}px at ${x}px ${y}px)`,
],
opacity: [1, 1],
},
{
duration: 400,
easing: 'ease-out',
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => {});
vt.finished