mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Owner answer: "paced connect: in". Measured on the checkpoint build: of the 140 ms between the click on Tiles and its first frame, about 100 ms was the six xterms being built inside the click, so nothing moved on screen for that long and the entrance could not start. openTileGrid now mounts every tile and lays the grid out as before, then _connectTilesPaced builds one terminal per animation frame, the focused tile's first, then reading order. The click paints its empty tiles in about 20 ms and the entrance plays while the terminals are built. The time until every tile has painted does not change: the load queue serves one capture at a time, so only the focused tile's connect is on its path, one frame later. Each tile still connects once, into its final cell (one fit, one PTY resize). - openTileGrid returns before the terminals exist, so a selection that focuses a tile whose terminal is not built yet hands the keyboard over in _connectTile (focusOnConnect), never when focus: false was asked, and to the newly focused tile when focus moved meanwhile. - _connectTile connects a tile once (entry.connected; a remount after Attach resets it), so a re-form or a remount before a tile's turn is never connected twice. - A grid closed or opened again meanwhile stops the old run (a run token), and asks for no further frames. Tests: tile-grid-paced-connect.test.ts (the order, the final cells, the keyboard, close and reopen, removed and remounted tiles); the tests that read connect or the terminal's focus right after openTileGrid now run the queued frames first (flushFrames in the vm harness), every assertion kept. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
311 lines
13 KiB
TypeScript
311 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,
|
|
flushFrames,
|
|
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);
|
|
flushFrames();
|
|
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;/);
|
|
});
|
|
});
|