diff --git a/decnet_web/src/index.css b/decnet_web/src/index.css index 65e1f99a..d5f6ff18 100644 --- a/decnet_web/src/index.css +++ b/decnet_web/src/index.css @@ -342,24 +342,22 @@ input:focus { * circle clip-path in useThemeToggle.ts owns the * reveal entirely. * - * Layering: the NEW theme snapshot sits behind, the - * OLD theme snapshot sits on top. The JS animation - * shrinks the old layer's clip-path from the - * viewport-covering circle down to circle(0) at the - * click point — burning away to reveal the new - * theme underneath. This avoids the one-frame flash - * you'd get by growing the new layer (where the - * default fully-opaque pseudo is visible for a tick - * before the clip-path animation registers). */ + * Layering: the OLD theme sits behind, the NEW theme + * sits on top and grows outward from the click point + * via a clip-path animation. The JS animation runs + * with fill: 'both' so the new layer starts clipped + * to circle(0) immediately (no opening flash) and + * stays at the final keyframe until pseudo teardown + * (no closing flash). */ ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; } -::view-transition-new(root) { +::view-transition-old(root) { z-index: 0; } -::view-transition-old(root) { +::view-transition-new(root) { z-index: 1; } diff --git a/decnet_web/src/lib/useThemeToggle.ts b/decnet_web/src/lib/useThemeToggle.ts index 663a292f..9ae992f9 100644 --- a/decnet_web/src/lib/useThemeToggle.ts +++ b/decnet_web/src/lib/useThemeToggle.ts @@ -70,27 +70,25 @@ function animateSwap(next: Theme, x: number, y: number): void { Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y), ); - /* Shrink the OLD layer (on top per index.css z-index rules) - * away from the click point, uncovering the NEW layer that - * sits behind. Going outside-in instead of inside-out avoids - * the one-frame flash where the default-opaque new pseudo - * would otherwise be visible before the clip-path registers. */ + /* Grow the NEW layer (on top per index.css z-index rules) + * outward from the click point, covering the OLD layer that + * sits behind. fill: 'both' pins both ends of the keyframe + * range so the start state (clipped to a 0-radius circle) + * is enforced before the first paint and the end state + * (full-viewport circle) holds through pseudo teardown — + * killing the flash on either end of the animation. */ document.documentElement.animate( { clipPath: [ - `circle(${endRadius}px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`, + `circle(${endRadius}px at ${x}px ${y}px)`, ], }, { duration: 520, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', - pseudoElement: '::view-transition-old(root)', - /* Without 'forwards' the keyframes release on completion - * and the pseudo reverts to its computed style (no - * clip-path), making the old layer flash back at full - * size for a frame before View Transitions tears it down. */ - fill: 'forwards', + pseudoElement: '::view-transition-new(root)', + fill: 'both', }, ); }).catch(() => { /* user-cancelled or unsupported pseudo, ignore */ });