Skip to content

Commit d890240

Browse files
nedtwiggclaude
andcommitted
Kill animation: fade the ghost instead of crushing it
The directional crush (to-left/right/up/down/hcenter/vcenter/br) added motion that competed with the grower's FLIP reveal, which already carries the direction. Replacing with a uniform opacity fade reads cleaner and eliminates the case-detection branching — every kill path (edge / middle / last-pane) now uses the same .pane-fading-out class. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a6e36b1 commit d890240

File tree

3 files changed

+26
-69
lines changed

3 files changed

+26
-69
lines changed

docs/specs/layout.md

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -297,22 +297,17 @@ When a pane is added, its dockview group element gets a directional `.pane-spawn
297297

298298
The direction is carried via `FreshlySpawnedContext` — a `Map<paneId, SpawnDirection>` written by the spawn call site and consumed once by `TerminalPanel`'s `useLayoutEffect` on first mount.
299299

300-
### Kill (ghost crush + FLIP reclaim)
300+
### Kill (ghost fade + FLIP reclaim)
301301

302302
`orchestrateKill(api, killedId)` in `Pond.tsx` runs on kill confirmation:
303303

304304
1. Snapshot `getBoundingClientRect` for every other panel's group element.
305305
2. `destroyTerminal` + `api.removePanel`; dockview snaps the layout.
306306
3. Measure post-rects. Any panel whose rect grew is a "grower."
307-
4. Pick a crush direction by comparing growers' post-rect centers against the killed rect's center:
308-
- Growers to one side only (horizontal axis) → `.pane-crushing-to-{left,right}`.
309-
- Growers to one side only (vertical axis) → `.pane-crushing-{up,down}`.
310-
- Growers on both sides of the killed pane's center (horizontal or vertical) → `.pane-crushing-to-{hcenter,vcenter}` (middle-kill crush).
311-
- No growers (last-pane kill) → `.pane-crushing-to-br` (crushes toward bottom-right corner, opposite of where the auto-spawned replacement reveals from).
312-
5. Mount a solid `var(--color-surface)` ghost overlay at the killed rect (`position: fixed`, `z-index: 55`) with that class; it removes itself on `animationend` with a 1s timeout safety net.
313-
6. For each grower, apply an inline `clip-path: inset(...)` with the newly-claimed territory clipped off, force a reflow, then transition to `inset(0)`. This reveals the grower into the vacated space without affecting `getBoundingClientRect`. Clears on `transitionend`.
314-
315-
Case detection is purely rect-based (measure before and after removal), so 2-pane splits, linear 3+ rows/columns, and nested splits all fall through the same code path.
307+
4. Mount a solid `var(--color-surface)` ghost overlay at the killed rect (`position: fixed`, `z-index: 55`) with the `.pane-fading-out` class; it removes itself on `animationend` with a 1s timeout safety net. A uniform fade works for every case (edge/middle/last-pane kill) because the directional cue is already carried by the grower's FLIP reveal.
308+
5. For each grower, apply an inline `clip-path: inset(...)` with the newly-claimed territory clipped off, force a reflow, then transition to `inset(0)`. This reveals the grower into the vacated space without affecting `getBoundingClientRect`. Clears on `transitionend`.
309+
310+
Case handling is purely rect-based (measure before and after removal), so 2-pane splits, linear 3+ rows/columns, and nested splits all fall through the same code path with no per-case branching.
316311

317312
### Auto-spawn delay
318313

lib/src/components/Pond.tsx

Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -991,16 +991,15 @@ function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: {
991991
// Orchestrates the visual reclaim when a pane is killed. Captures pre-rects of
992992
// every surviving pane's group element, removes the panel (dockview snaps the
993993
// layout), then:
994-
// - Renders a ghost overlay at the killed pane's position with a clip-path
995-
// crush animation (direction chosen from how the layout actually changed).
994+
// - Renders a ghost overlay at the killed pane's position and fades it out.
996995
// - Applies a FLIP-style clip-path reveal on each grower so its newly claimed
997996
// territory is hidden at start and swept in by the animation. We use
998997
// clip-path (not transform) because transform would corrupt the grower's
999998
// getBoundingClientRect and make SelectionOverlay lag.
1000999
//
1001-
// For the "no surviving panes" case (Case C), we only render the ghost crushing
1002-
// to the bottom-right corner; the auto-spawn handler (onDidRemovePanel) will
1003-
// create a fresh pane tagged with 'top-left' spawn direction.
1000+
// For the "no surviving panes" case (last-pane kill), only the ghost fade runs;
1001+
// the delayed auto-spawn (see onDidRemovePanel) creates a fresh pane after the
1002+
// fade, tagged with 'top-left' spawn direction.
10041003
function orchestrateKill(api: DockviewApi, killedId: string): void {
10051004
const panel = api.getPanel(killedId);
10061005
if (!panel) return;
@@ -1030,7 +1029,7 @@ function orchestrateKill(api: DockviewApi, killedId: string): void {
10301029
destroyTerminal(killedId);
10311030
api.removePanel(panel);
10321031

1033-
// Classify growers (panes whose rect changed) and pick the crush direction.
1032+
// Collect growers (panes whose rect changed) for the FLIP reveal below.
10341033
interface Grower { el: HTMLElement; preRect: DOMRect; postRect: DOMRect; }
10351034
const growers: Grower[] = [];
10361035
for (const p of api.panels) {
@@ -1043,29 +1042,7 @@ function orchestrateKill(api: DockviewApi, killedId: string): void {
10431042
growers.push({ el: pre.el, preRect: pre.rect, postRect });
10441043
}
10451044

1046-
let crushClass = 'pane-crushing-to-br';
1047-
if (killedRect && growers.length > 0) {
1048-
const horizontal = growers.some(g =>
1049-
Math.abs(g.postRect.width - g.preRect.width) > Math.abs(g.postRect.height - g.preRect.height)
1050-
);
1051-
const killedCenterX = (killedRect.left + killedRect.right) / 2;
1052-
const killedCenterY = (killedRect.top + killedRect.bottom) / 2;
1053-
if (horizontal) {
1054-
const hasLeft = growers.some(g => (g.postRect.left + g.postRect.right) / 2 < killedCenterX);
1055-
const hasRight = growers.some(g => (g.postRect.left + g.postRect.right) / 2 > killedCenterX);
1056-
if (hasLeft && hasRight) crushClass = 'pane-crushing-to-hcenter';
1057-
else if (hasLeft) crushClass = 'pane-crushing-to-right';
1058-
else crushClass = 'pane-crushing-to-left';
1059-
} else {
1060-
const hasAbove = growers.some(g => (g.postRect.top + g.postRect.bottom) / 2 < killedCenterY);
1061-
const hasBelow = growers.some(g => (g.postRect.top + g.postRect.bottom) / 2 > killedCenterY);
1062-
if (hasAbove && hasBelow) crushClass = 'pane-crushing-to-vcenter';
1063-
else if (hasAbove) crushClass = 'pane-crushing-down';
1064-
else crushClass = 'pane-crushing-up';
1065-
}
1066-
}
1067-
1068-
// Mount ghost overlay at the killed pane's pre-removal rect.
1045+
// Mount ghost overlay at the killed pane's pre-removal rect and fade it out.
10691046
if (killedRect) {
10701047
const ghost = document.createElement('div');
10711048
Object.assign(ghost.style, {
@@ -1078,15 +1055,14 @@ function orchestrateKill(api: DockviewApi, killedId: string): void {
10781055
zIndex: '55',
10791056
pointerEvents: 'none',
10801057
});
1081-
ghost.classList.add(crushClass);
1058+
ghost.classList.add('pane-fading-out');
10821059
document.body.appendChild(ghost);
10831060
const cleanup = () => {
10841061
if (ghost.isConnected) ghost.remove();
10851062
};
10861063
ghost.addEventListener('animationend', cleanup, { once: true });
1087-
// Safety: reduced-motion + forwards fill mode still fires animationend, but
1088-
// if the browser ever elides the event (or the element is detached early),
1089-
// a timeout ensures the ghost doesn't linger.
1064+
// Safety: if the browser ever elides animationend (or the element is
1065+
// detached early), a timeout ensures the ghost doesn't linger.
10901066
setTimeout(cleanup, 1000);
10911067
}
10921068

lib/src/theme.css

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -167,35 +167,21 @@ body.vscode-light {
167167
.pane-spawning-from-top { animation: pane-spawn-from-top 440ms cubic-bezier(0.22, 1, 0.36, 1); }
168168
.pane-spawning-from-top-left { animation: pane-spawn-from-top-left 440ms cubic-bezier(0.22, 1, 0.36, 1); }
169169

170-
/* Pane kill — crush the ghost overlay in the direction of the replacing neighbor
171-
* (or to the centerline for a middle kill, or to the bottom-right corner when no
172-
* replacer exists and the auto-spawn takes over). */
173-
174-
@keyframes pane-crush-to-left { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 100% 0 0); } }
175-
@keyframes pane-crush-to-right { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 0 100%); } }
176-
@keyframes pane-crush-up { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 100% 0); } }
177-
@keyframes pane-crush-down { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(100% 0 0 0); } }
178-
@keyframes pane-crush-to-hcenter { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 50% 0 50%); } }
179-
@keyframes pane-crush-to-vcenter { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(50% 0 50% 0); } }
180-
@keyframes pane-crush-to-br { from { clip-path: inset(0 0 0 0); opacity: 1; } to { clip-path: inset(100% 0 0 100%); opacity: 0; } }
181-
182-
.pane-crushing-to-left { animation: pane-crush-to-left 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
183-
.pane-crushing-to-right { animation: pane-crush-to-right 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
184-
.pane-crushing-up { animation: pane-crush-up 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
185-
.pane-crushing-down { animation: pane-crush-down 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
186-
.pane-crushing-to-hcenter { animation: pane-crush-to-hcenter 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
187-
.pane-crushing-to-vcenter { animation: pane-crush-to-vcenter 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
188-
.pane-crushing-to-br { animation: pane-crush-to-br 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
170+
/* Pane kill — fade the ghost overlay out. Directional crushing read as noisy
171+
* relative to the grower reveal already carrying the directional cue; a
172+
* uniform fade is cleaner and works for every case (edge kill, middle kill,
173+
* last-pane kill) with one rule. */
174+
175+
@keyframes pane-fade-out {
176+
from { opacity: 1; }
177+
to { opacity: 0; }
178+
}
179+
180+
.pane-fading-out { animation: pane-fade-out 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
189181

190182
@media (prefers-reduced-motion: reduce) {
191183
.pane-spawning-from-left,
192184
.pane-spawning-from-top,
193185
.pane-spawning-from-top-left,
194-
.pane-crushing-to-left,
195-
.pane-crushing-to-right,
196-
.pane-crushing-up,
197-
.pane-crushing-down,
198-
.pane-crushing-to-hcenter,
199-
.pane-crushing-to-vcenter,
200-
.pane-crushing-to-br { animation: none; }
186+
.pane-fading-out { animation: none; }
201187
}

0 commit comments

Comments
 (0)