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
* 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;
}

View File

@@ -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 */ });