diff --git a/libs/yggdrasil-core/src/style.css b/libs/yggdrasil-core/src/style.css index 53fb6f9..52d6bbf 100644 --- a/libs/yggdrasil-core/src/style.css +++ b/libs/yggdrasil-core/src/style.css @@ -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 过渡, diff --git a/libs/yggdrasil-core/src/theme-transition.test.ts b/libs/yggdrasil-core/src/theme-transition.test.ts index ad33ce1..b3a9e05 100644 --- a/libs/yggdrasil-core/src/theme-transition.test.ts +++ b/libs/yggdrasil-core/src/theme-transition.test.ts @@ -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; }); diff --git a/libs/yggdrasil-core/src/theme-transition.ts b/libs/yggdrasil-core/src/theme-transition.ts index 18fa6d2..d6010d8 100644 --- a/libs/yggdrasil-core/src/theme-transition.ts +++ b/libs/yggdrasil-core/src/theme-transition.ts @@ -1,21 +1,13 @@ /** * 圆形展开主题切换动画(View Transitions API)。 * - * 点击按钮时,新主题页面从点击点 (x,y) 以圆形向外展开覆盖全屏。 - * - * 实现方式:使用 Web Animations API 的 pseudoElement 选项,在 vt.ready 中 - * 直接对 ::view-transition-old/new(root) 伪元素创建动画。相比注入