fix(theme): lock VT pseudo-element opacity/blend via CSS !important

Gray expanding circle was caused by UA default animations (fade-in/out)
and mix-blend-mode: plus-lighter not being fully overridden by WAAPI
opacity animations alone.

Fix: use CSS with !important to hard-lock the VT pseudo-elements:
- animation: none !important — kills UA fade-in/out
- opacity: 1 !important — both layers stay fully opaque
- mix-blend-mode: normal !important — no color addition

WAAPI now only controls clip-path on ::view-transition-new(root),
which doesn't conflict with any CSS property (no CSS clip-path exists).
This cleanly separates concerns: CSS handles static properties,
WAAPI handles the dynamic circle expansion.
This commit is contained in:
xfy 2026-06-26 18:08:28 +08:00
parent f0ea1a294f
commit 0f9c8fb8a3
3 changed files with 17 additions and 24 deletions

View File

@ -1,11 +1,14 @@
/* ========== 主题切换圆形展开(View Transitions API) ========== */ /* ========== 主题切换圆形展开(View Transitions API) ========== */
/* mix-blend-mode 不可通过 Web Animations API 设置,只能用 CSS /* 硬锁 VT 伪元素样式:禁用 UA 默认的 fade-in/out,
UA 默认 plus-lighter 会导致新旧层颜色叠加发亮,需覆盖为 normal 强制两层都保持完全不透明 + 正常混合模式
author 样式天然覆盖 UA 样式,无需 !important */ WAAPI 仅控制 ::view-transition-new clip-path 展开,不涉及 opacity
!important 确保覆盖 UA origin */
::view-transition-old(root), ::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {
mix-blend-mode: normal; animation: none !important;
opacity: 1 !important;
mix-blend-mode: normal !important;
} }
/* View Transition 期间强制禁用所有 CSS 过渡, /* View Transition 期间强制禁用所有 CSS 过渡,

View File

@ -71,11 +71,11 @@ describe('startThemeTransition', () => {
// ready 后通过 Web Animations API 控制动画 // ready 后通过 Web Animations API 控制动画
await readyP; await readyP;
await Promise.resolve(); await Promise.resolve();
// animate 应被调用两次:一次 old(opacity),一次 new(clipPath + opacity) // animate 应被调用一次:clip-path 展开 on new
expect(animateSpy).toHaveBeenCalledTimes(2); expect(animateSpy).toHaveBeenCalledTimes(1);
// 第二次调用(new)应包含 clipPath // 调用应包含 clipPath + pseudoElement
const calls = animateSpy.mock.calls as unknown[][]; const calls = animateSpy.mock.calls as unknown[][];
const newCall = calls[1]; const newCall = calls[0];
expect(newCall[0]).toHaveProperty('clipPath'); expect(newCall[0]).toHaveProperty('clipPath');
expect(newCall[1]).toHaveProperty('pseudoElement', '::view-transition-new(root)'); expect(newCall[1]).toHaveProperty('pseudoElement', '::view-transition-new(root)');

View File

@ -82,28 +82,18 @@ export function startThemeTransition(x: number, y: number): void {
vt.ready vt.ready
.then(() => { .then(() => {
console.log('[tt] ready, animating via WAAPI'); console.log('[tt] ready, animating clip-path via WAAPI');
// ── Web Animations API ── // CSS 已通过 animation:none + opacity:1 (!important) 锁定两层:
// script-created 动画优先级高于 CSS 动画(含 UA 默认 fade-in/out), // OLD 保持完全可见(暗色底图),NEW 保持完全不透明。
// 天然覆盖,无需 !important,无残留样式问题。 // WAAPI 只需控制 NEW 的 clip-path 实现圆形展开。
// script-created Animation 优先级高于 CSS animation(已被 none 禁用),
// OLD: 覆盖 UA 默认的 fade-out,保持完全可见作为底图 // 只添加 clip-path 动画,不与 CSS 冲突。
document.documentElement.animate(
{ opacity: [1, 1] },
{
duration: 400,
pseudoElement: '::view-transition-old(root)',
},
);
// NEW: 覆盖 UA 默认的 fade-in,改为 clip-path 圆形展开
document.documentElement.animate( document.documentElement.animate(
{ {
clipPath: [ clipPath: [
`circle(0px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`,
`circle(${maxR}px at ${x}px ${y}px)`, `circle(${maxR}px at ${x}px ${y}px)`,
], ],
opacity: [1, 1],
}, },
{ {
duration: 400, duration: 400,