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