fix(decnet_web/Layout): outward theme reveal, no flash either end

ANTI prefers the new theme growing outward from the click point
(visually clearer cause-and-effect than the old theme burning
away). The original outward implementation flashed at the start
because the new pseudo defaulted to its computed style (no
clip-path = fully visible) for one frame before the JS animation
registered.

Switching the animation's fill from 'forwards' to 'both' enforces
the start keyframe (circle(0) at click point) before the first
paint, in addition to pinning the end keyframe through pseudo
teardown. New layer is invisible until the animation begins,
fully visible until cleanup. No flash either end.
This commit is contained in:
2026-05-09 04:17:07 -04:00
parent 6d1fc3a081
commit ccff1467b1
2 changed files with 19 additions and 23 deletions

View File

@@ -342,24 +342,22 @@ input:focus {
* circle clip-path in useThemeToggle.ts owns the * circle clip-path in useThemeToggle.ts owns the
* reveal entirely. * reveal entirely.
* *
* Layering: the NEW theme snapshot sits behind, the * Layering: the OLD theme sits behind, the NEW theme
* OLD theme snapshot sits on top. The JS animation * sits on top and grows outward from the click point
* shrinks the old layer's clip-path from the * via a clip-path animation. The JS animation runs
* viewport-covering circle down to circle(0) at the * with fill: 'both' so the new layer starts clipped
* click point — burning away to reveal the new * to circle(0) immediately (no opening flash) and
* theme underneath. This avoids the one-frame flash * stays at the final keyframe until pseudo teardown
* you'd get by growing the new layer (where the * (no closing flash). */
* default fully-opaque pseudo is visible for a tick
* before the clip-path animation registers). */
::view-transition-old(root), ::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {
animation: none; animation: none;
mix-blend-mode: normal; mix-blend-mode: normal;
} }
::view-transition-new(root) { ::view-transition-old(root) {
z-index: 0; z-index: 0;
} }
::view-transition-old(root) { ::view-transition-new(root) {
z-index: 1; z-index: 1;
} }

View File

@@ -70,27 +70,25 @@ function animateSwap(next: Theme, x: number, y: number): void {
Math.max(x, window.innerWidth - x), Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y), Math.max(y, window.innerHeight - y),
); );
/* Shrink the OLD layer (on top per index.css z-index rules) /* Grow the NEW layer (on top per index.css z-index rules)
* away from the click point, uncovering the NEW layer that * outward from the click point, covering the OLD layer that
* sits behind. Going outside-in instead of inside-out avoids * sits behind. fill: 'both' pins both ends of the keyframe
* the one-frame flash where the default-opaque new pseudo * range so the start state (clipped to a 0-radius circle)
* would otherwise be visible before the clip-path registers. */ * 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( document.documentElement.animate(
{ {
clipPath: [ clipPath: [
`circle(${endRadius}px at ${x}px ${y}px)`,
`circle(0px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
], ],
}, },
{ {
duration: 520, duration: 520,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)', easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
pseudoElement: '::view-transition-old(root)', pseudoElement: '::view-transition-new(root)',
/* Without 'forwards' the keyframes release on completion fill: 'both',
* 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',
}, },
); );
}).catch(() => { /* user-cancelled or unsupported pseudo, ignore */ }); }).catch(() => { /* user-cancelled or unsupported pseudo, ignore */ });