Files
Codeman/test/tile-grid-motion.test.ts
T
Codeman maintainer 78bfe53dc7 feat(tiles): the grid opens and closes with a short animation
Owner request: "when clicking on the tile button first make this
animation nicer". Recorded before: the click froze the page, six empty
tiles cut in at once, each tile's history then scrolled in visibly as
its load landed, and closing showed an empty single view for about a
quarter of a second before its own replay scrolled in.

The grid's own motion, on by default (not an entrance-animations.js
theme, which are off by default):

- Opening: each tile fades and settles in (opacity, translateY 6px,
  scale .97), 180 ms, 24 ms apart in reading order: the last of six is
  done at 300 ms. Its terminal stays transparent until the load queue
  reports its first capture done, then fades in whole (160 ms), so no
  replay scrolls by; a 15 s backstop shows it should that never come. A
  tile added later enters the same way.
- Closing with the toggle (button, Ctrl+Shift+G; owner answer: only
  these): the close stays synchronous, and a still copy of the tiles
  (clones: no xterm, socket or listener; inert, aria-hidden, no pointer)
  dims at once over the stage, holds until the single view's
  selectSession has replayed its session (at most 700 ms), then fades
  out. No empty single view between the two. A reopen drops a copy
  still showing; a web tab hides it.
- A re-form to another count fades the old grid's copy out at once while
  the new tiles enter.
- The count menu fades in (140 ms).

Every one animates opacity and transform only, so FitAddon measures the
final cell and each tile still sends one PTY resize (#464); nothing
moves, and no copy is made, under prefers-reduced-motion.

Tests (tile-grid-motion.test.ts): the stagger, the reveal and its
backstop, the same fits and connects with and without motion, the held
copy (released on settle, by its cap, removed on the last tile-leave or
its fallback), only the toggle animates, a reopen purges, the zoomed
tile alone, the re-form, reduced motion, and a CSS guard that every new
keyframe touches only opacity and transform. The vm harness gains
style.setProperty, cloneNode, isConnected and lastElementChild.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 04:24:54 +02:00

309 lines
13 KiB
TypeScript

/**
* @fileoverview The grid's open and close animation (owner request: "when
* clicking on the tile button first make this animation nicer").
*
* - Opening: every tile mounts with `.tile--entering` and its place in reading
* order (`--tile-enter-index`, the CSS stagger), cleared by its own
* `tile-enter` animationend only. Its terminal stays transparent
* (`.tile--revealing`) until the load queue reports its first capture done,
* with a backstop timer.
* - The animation adds no fit and no PTY resize: a grid opened with motion
* fits and connects its tiles exactly as one opened under reduced motion.
* - Closing with the Tiles toggle (button, Ctrl+Shift+G; owner answer 4): a
* still copy of the tiles (clones, no TerminalTile) dims over the stage and
* waits until the single view's selection has settled, at most 700 ms, then
* fades and removes itself (animationend, or a fallback timer). Every other
* close cuts as before. A reopen drops a copy still showing.
* - Re-forming to another count: the old grid's copy fades at once, the new
* tiles enter staggered, the tiles that stay do not.
* - Reduced motion: no entrance, no copy, no stagger property.
* - The CSS: every new keyframe animates opacity and transform only, all of
* it stops under prefers-reduced-motion, and a web tab hides the copy.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
body,
bySelector,
main,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const tiles = () => section.children.filter((c) => c.classList.contains('tile'));
const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts'));
const end = (el: FakeEl, animationName: string, target: FakeEl = el) => el.dispatch('animationend', { target, animationName });
function reducedMotion(on: boolean) {
windowStub.matchMedia = (q: string) => ({ matches: on && q.includes('reduce'), addEventListener: vi.fn() });
}
beforeEach(() => {
resetGridHarness();
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid';
bySelector.set('.btn-tile-grid', btn);
});
afterEach(() => {
vi.useRealTimers();
});
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
return app;
}
describe('opening', () => {
it('each tile enters, staggered in reading order', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 4), { focusedId: 's-c' });
expect(tiles().map((t) => t.classList.contains('tile--entering'))).toEqual([true, true, true, true]);
expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']);
});
it('a tile stops entering on its own tile-enter animationend, not on a child or another animation', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const el = tiles()[0];
end(el, 'tile-needs-pulse');
end(el, 'tile-enter', new FakeEl());
expect(el.classList.contains('tile--entering')).toBe(true);
end(el, 'tile-enter');
expect(el.classList.contains('tile--entering')).toBe(false);
expect(tiles()[1].classList.contains('tile--entering')).toBe(true);
});
it('a terminal stays transparent until the load queue reports its first capture done', () => {
// A queue that hands the grid's own state callback to the test.
const Queue = windowStub.TileLoadQueue;
let report: (tile: unknown, state: string) => void = () => {};
windowStub.TileLoadQueue = class {
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
report = opts.onChange;
}
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
try {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
app._tileLoadQueue();
expect(tiles().every((t) => t.classList.contains('tile--revealing'))).toBe(true);
const tileA = app._tileFor('s-a');
report(tileA, 'queued');
report(tileA, 'running');
expect(tiles()[0].classList.contains('tile--revealing')).toBe(true);
report(tileA, 'idle');
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
expect(tiles()[1].classList.contains('tile--revealing')).toBe(true);
// A later load (a reconnect refresh) never hides it again.
report(tileA, 'running');
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
} finally {
windowStub.TileLoadQueue = Queue;
}
});
it('a backstop timer shows a terminal whose load never reports back', () => {
vi.useFakeTimers();
const app = gridApp();
app.openTileGrid(['s-a']);
vi.advanceTimersByTime(14_999);
expect(tiles()[0].classList.contains('tile--revealing')).toBe(true);
vi.advanceTimersByTime(1);
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
});
it('adds no fit and no PTY resize: tiles fit and connect exactly as under reduced motion', () => {
vi.useFakeTimers();
const counts = (motion: boolean) => {
resetGridHarness();
reducedMotion(!motion);
const app = gridApp();
app.openTileGrid(IDS);
vi.advanceTimersByTime(1000);
return FakeTile.all.map((t) => [t.connect.mock.calls.length, t.fit.mock.calls.length, t.localFit.mock.calls.length]);
};
const withMotion = counts(true);
const without = counts(false);
expect(withMotion).toEqual(without);
// One connect per tile (its one resize goes out when its socket opens).
expect(withMotion.every(([connects]) => connects === 1)).toBe(true);
});
it('a tile added later enters too; one dropped on a tile enters in its place', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
for (const t of tiles()) end(t, 'tile-enter');
app.addTile('s-c');
const added = tiles().find((t) => t.dataset.sessionId === 's-c')!;
expect(added.classList.contains('tile--entering')).toBe(true);
expect(tiles().filter((t) => t.classList.contains('tile--entering'))).toHaveLength(1);
});
});
describe('closing with the Tiles toggle', () => {
it('leaves a still copy of the tiles over the stage: clones, inert, no terminals', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 3));
const before = FakeTile.all.length;
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(ghosts()).toHaveLength(1);
const layer = ghosts()[0];
expect(layer.inert).toBe(true);
expect(layer.attrs['aria-hidden']).toBe('true');
expect(layer.children.map((g) => g.dataset.sessionId)).toEqual(['s-a', 's-b', 's-c']);
expect(layer.children.every((g) => g.classList.contains('tile--leaving'))).toBe(true);
expect(layer.children.some((g) => g.classList.contains('tile--entering'))).toBe(false);
// Copies only: no TerminalTile was made for them, the real ones are gone.
expect(FakeTile.all).toHaveLength(before);
expect(FakeTile.all.every((t) => t._destroyed)).toBe(true);
expect(tiles()).toHaveLength(0);
});
it('holds until the single view has its content, then fades and goes', async () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
let settle: () => void = () => {};
app.selectSession = vi.fn(() => new Promise<void>((r) => (settle = r)));
app.toggleTileGrid();
const layer = ghosts()[0];
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false);
settle();
// The selection's promise comes from another realm (the test's), so its
// settling reaches the grid after a few microtasks: a macrotask covers them.
await new Promise((r) => setTimeout(r, 0));
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
end(layer, 'tile-leave', layer.children[0]);
expect(ghosts()).toHaveLength(1);
end(layer, 'tile-leave', layer.lastElementChild!);
expect(ghosts()).toHaveLength(0);
});
it('a single view that never settles still gets it gone: 700 ms, then the fade fallback', () => {
vi.useFakeTimers();
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
app.toggleTileGrid();
const layer = ghosts()[0];
vi.advanceTimersByTime(699);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false);
vi.advanceTimersByTime(1);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
vi.advanceTimersByTime(450);
expect(ghosts()).toHaveLength(0);
});
it('a zoomed grid leaves a copy of the zoomed tile only', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 3));
app.zoomTile('s-b');
app.toggleTileGrid();
expect(ghosts()[0].children.map((g) => g.dataset.sessionId)).toEqual(['s-b']);
});
it('every other close cuts as before: no copy', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
app.closeTileGrid({ reselect: false });
expect(ghosts()).toHaveLength(0);
app.openTileGrid(IDS.slice(0, 2));
// A user pick of a tab that is not tiled leaves the grid (decision 1).
app.closeTileGrid({ keepStored: true });
expect(ghosts()).toHaveLength(0);
});
it('reopening drops a copy still showing', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
app.toggleTileGrid();
expect(ghosts()).toHaveLength(1);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
app.toggleTileGrid();
expect(ghosts()).toHaveLength(0);
expect(app._tilesOwnTerminal()).toBe(true);
});
});
describe('re-forming to another count', () => {
it('the old grid fades at once, the new tiles enter staggered, the ones that stay do not', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
for (const t of tiles()) end(t, 'tile-enter');
app.openTileCountMenu({ preventDefault: vi.fn() });
const menu = body.children.find((c) => c.id === 'tileCountMenu')!;
menu.children.find((c) => c.dataset.count === '6')!.dispatch('click', { stopPropagation: vi.fn() });
const layer = ghosts()[0];
expect(layer.classList.contains('tile-grid-ghosts--now')).toBe(true);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
const entering = tiles().filter((t) => t.classList.contains('tile--entering'));
expect(entering.map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']);
expect(entering.map((t) => t.dataset.sessionId)).not.toContain('s-a');
});
});
describe('reduced motion', () => {
it('no entrance, no stagger property, no copy on close', () => {
reducedMotion(true);
const app = gridApp();
app.openTileGrid(IDS.slice(0, 3));
expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false);
expect(tiles().some((t) => '--tile-enter-index' in t.style)).toBe(false);
app.toggleTileGrid();
expect(ghosts()).toHaveLength(0);
});
});
describe('the CSS', () => {
const keyframes = (name: string) => {
const m = css.match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`));
return m ? m[1] : '';
};
it('every new keyframe animates opacity and transform only', () => {
for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) {
const body = keyframes(name);
expect(body, name).not.toBe('');
const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]);
expect(props.length, name).toBeGreaterThan(0);
expect(props.filter((p) => p !== 'opacity' && p !== 'transform'), name).toEqual([]);
}
});
it('the terminal reveal is an opacity transition only', () => {
expect(css).toMatch(/\.tile-body \.xterm \{\s*transition: opacity 160ms ease-out;\s*\}/);
expect(css).toMatch(/\.tile\.tile--revealing \.tile-body \.xterm \{\s*opacity: 0;\s*\}/);
});
it('stays within about 150 to 300 ms: 180 ms per tile, 24 ms apart, the last of six done at 300', () => {
expect(css).toMatch(/\.tile\.tile--entering \{\s*animation: tile-enter 180ms [^;]+;\s*animation-delay: calc\(var\(--tile-enter-index, 0\) \* 24ms\);/);
});
it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => {
const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,'));
const reduced = block.slice(0, block.indexOf('\n}\n'));
for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) {
expect(reduced).toContain(sel);
}
expect(reduced).toMatch(/animation: none;/);
expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/);
expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/);
});
});