feat(tiles): Tile Animations setting, entrance styles for the tile grid

Tiles become the fifth entrance surface (data-tile-anim), switched on in App
Settings > Appearance > Tile Animations and OFF by default: the default is the
grid's own quick fade (`settle`), exactly as before.

A styled tile plays in two beats: its frame enters as it mounts (fly out of
its session tab, dealt from the Tiles button, CRT power-on, beam down from its
tab, cascade, pop, soft), and its screen then plays the terminal pane style of
the entrance theme when its first capture lands, through the same
html[data-term-anim] rules on .tile-body. Each style has its own exit when the
Tiles button closes the grid (back into the tabs, a CRT switch-off, ...).

Picking an Entrance Animations theme presets the tile style (new Launch theme:
tiles fly from the tabs); the theme readout ignores the tile row, so changing
it never shows "Custom". Frames move transform and opacity only (one fit and
one PTY resize per tile), a reload's restore always settles, and nothing moves
under reduced motion. The lab (?animlab=1) gets a Tile grid group, a cascade
order picker and in-place replay / close + reopen.

Also removes the terminal pane's `boot` entrance style (owner decision); a
saved `boot` falls back to off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 17:43:55 +02:00
parent 3a0cee6b90
commit decd263b17
10 changed files with 1556 additions and 122 deletions
+243
View File
@@ -0,0 +1,243 @@
/**
* @fileoverview tile-grid.js's side of the tile entrance styles (the entrance
* module, entrance-animations.js, picks the style and times it):
*
* - `settle` (the default, and what the harness gets with no entrance module
* loaded) is the grid's own motion, untouched: tile-grid-motion.test.ts.
* - Any other style hands each mounted tile to `_stageTileEntrance`, which
* arms the backstop that ends the entrance if animationend never comes.
* - `.tile--entering` comes off on the tile's own `tile-enter*` end only,
* never on a ::before wash ending first.
* - `off` mounts tiles with no entrance and no screen beat.
* - A reload's restore settles whatever the style, and owes no screen beat.
* - The screen beat (`playTileScreenEntrance`) plays once, when the load queue
* reports a tile's first capture done, and only for a tile style (never
* with the default `settle`).
* - The closing grid's still copy hands its copies to `_stageTileExit` and
* waits as long as it says before the fallback removes the copy.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
localStore,
main,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d'];
const tiles = () => section.children.filter((c) => c.classList.contains('tile'));
const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts'));
/** An app whose entrance module picks `style`, with the module's hooks recorded. */
function styledApp(style: string): GridApp {
const app = makeGridApp(IDS);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
app.tileEntranceStyle = () => style;
app._stageTileEntrance = vi.fn();
app.playTileScreenEntrance = vi.fn();
return app;
}
/** Swaps in a load queue that hands the grid's state callback to the test. */
function captureQueue() {
const Queue = windowStub.TileLoadQueue;
const box: { report: (tile: unknown, state: string) => void; restore: () => void } = {
report: () => {},
restore: () => {
windowStub.TileLoadQueue = Queue;
},
};
windowStub.TileLoadQueue = class {
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
box.report = opts.onChange;
}
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
return box;
}
beforeEach(() => {
resetGridHarness();
});
afterEach(() => {
vi.useRealTimers();
});
describe('a themed entrance', () => {
it('hands every mounted tile to the entrance module, entering and in reading order', () => {
const app = styledApp('fly');
app.openTileGrid(IDS.slice(0, 3), { focusedId: 's-b' });
expect(app._stageTileEntrance).toHaveBeenCalledTimes(3);
const calls = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.map((c) => c[1])).toEqual(['s-a', 's-b', 's-c']);
for (const [el] of calls) expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
});
it('the backstop the module arms ends the entrance when animationend never comes', () => {
vi.useFakeTimers();
const app = styledApp('crt');
app.openTileGrid(['s-a']);
const [el, , setBackstop] = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls[0];
(el as FakeEl).classList.add('tile--enter-themed', 'tile--enter-hold');
setBackstop(800);
vi.advanceTimersByTime(799);
expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
vi.advanceTimersByTime(1);
expect((el as FakeEl).className.split(' ')).not.toContain('tile--entering');
expect((el as FakeEl).classList.contains('tile--enter-themed')).toBe(false);
expect((el as FakeEl).classList.contains('tile--enter-hold')).toBe(false);
});
it('ends on its own tile-enter-* animationend, never on a ::before wash or a child', () => {
const app = styledApp('crt');
app.openTileGrid(['s-a']);
const el = tiles()[0];
el.dispatch('animationend', { target: el, animationName: 'win-enter-crt-flash', pseudoElement: '::before' });
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '::before' });
el.dispatch('animationend', { target: new FakeEl(), animationName: 'tile-enter-crt' });
expect(el.classList.contains('tile--entering')).toBe(true);
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '' });
expect(el.classList.contains('tile--entering')).toBe(false);
});
it('`off` mounts tiles with no entrance and owes them no screen beat', () => {
const queue = captureQueue();
try {
const app = styledApp('off');
app.openTileGrid(['s-a', 's-b']);
expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
} finally {
queue.restore();
}
});
it('`settle` stays on the grid default: no hand-off to the module', () => {
const app = styledApp('settle');
app.openTileGrid(['s-a', 's-b']);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1']);
});
});
describe('the screen beat', () => {
it('never plays with the default settle: the grid behaves exactly as before', () => {
const queue = captureQueue();
try {
const app = styledApp('settle');
app.openTileGrid(['s-a']);
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
} finally {
queue.restore();
}
});
it('plays once, on the first capture landing, on that tile body', () => {
const queue = captureQueue();
try {
const app = styledApp('fly');
app.openTileGrid(['s-a', 's-b']);
app._tileLoadQueue();
const tileA = app._tileFor('s-a');
queue.report(tileA, 'running');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
queue.report(tileA, 'idle');
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
expect(app.playTileScreenEntrance).toHaveBeenCalledWith(app._tileGrid.tiles.get('s-a').body);
// A later load (a reconnect refresh) plays nothing again.
queue.report(tileA, 'running');
queue.report(tileA, 'idle');
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
} finally {
queue.restore();
}
});
});
describe('a reload restoring the grid', () => {
it('settles whatever the style, and owes no screen beat (nothing animates on page load)', () => {
const queue = captureQueue();
try {
localStore.set(
'codeman:tile-grid',
JSON.stringify({
v: 1,
open: true,
ids: ['s-a', 's-b'],
focused: 's-a',
zoomed: null,
colFr: [1, 1],
rowFr: [1],
})
);
const app = styledApp('deal');
expect(app._restoreTileGrid()).toBe(true);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
expect(tiles().every((t) => t.classList.contains('tile--entering'))).toBe(true);
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
// The quiet flag is gone after the restore: the next open is themed.
app.closeTileGrid({ reselect: false });
app.openTileGrid(['s-c']);
expect(app._stageTileEntrance).toHaveBeenCalledTimes(1);
} finally {
queue.restore();
}
});
});
describe('closing with the Tiles toggle', () => {
it('hands the copies to the exit, stripped of entrance state, and waits as long as it says', () => {
vi.useFakeTimers();
const app = styledApp('crt');
app._stageTileExit = vi.fn(() => 1200);
app.openTileGrid(IDS.slice(0, 2));
for (const t of tiles()) t.classList.add('tile--enter-themed', 'tile--enter-hold');
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
app.toggleTileGrid();
const [copies, opts] = (app._stageTileExit as ReturnType<typeof vi.fn>).mock.calls[0];
expect(opts).toEqual({ now: false });
expect((copies as Array<{ sessionId: string }>).map((c) => c.sessionId)).toEqual(['s-a', 's-b']);
const layer = ghosts()[0];
for (const g of layer.children) {
expect(g.classList.contains('tile--enter-themed')).toBe(false);
expect(g.classList.contains('tile--enter-hold')).toBe(false);
}
// Held 700 ms (the single view never settles here), then released: the
// fallback is the exit's 1200 ms, not the default fade's 450.
vi.advanceTimersByTime(700);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
vi.advanceTimersByTime(1199);
expect(ghosts()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(ghosts()).toHaveLength(0);
});
it('the copy goes on its last tile’s tile-leave-* end, not on a ::before wash', () => {
const app = styledApp('crt');
app._stageTileExit = vi.fn(() => 900);
app.openTileGrid(IDS.slice(0, 2));
app.toggleTileGrid();
const layer = ghosts()[0];
const last = layer.lastElementChild!;
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '::before' });
expect(ghosts()).toHaveLength(1);
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '' });
expect(ghosts()).toHaveLength(0);
});
});