Merge pull request #571: feat(tiles): Tile Animations setting, entrance styles for the tile grid

This commit is contained in:
Codeman maintainer
2026-10-10 02:53:25 +02:00
11 changed files with 1654 additions and 142 deletions
+25
View File
@@ -80,6 +80,31 @@ describe('App Settings modal structure', () => {
expect(system).toContain('id="appSettingsTunnelEnabled"');
});
/**
* Owner decision (2026-10-09): every animation setting has its own
* Animations section, right after Appearance, so it is easy to find. The
* selects are wired by id in entrance-animations.js, not by the load/save
* path above, so they get their own check here.
*/
it('keeps every animation setting in its own Animations section, after Appearance', () => {
const modal = settingsModal();
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
for (const list of [rail, order]) {
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
}
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
}
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(anim).toContain(`document.getElementById('${id}')`);
}
});
it('keeps Local Echo the first row of the second section', () => {
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
+123 -7
View File
@@ -31,6 +31,7 @@ const SURFACES = [
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
] as const;
/**
@@ -40,6 +41,11 @@ const SURFACES = [
*/
const CSS_LESS_STYLES = new Set(['off', 'fly']);
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
function isCssLess(attr: string, key: string): boolean {
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
}
function styleKeys(arrayName: string): string[] {
const start = animSource.indexOf(`const ${arrayName} = [`);
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
@@ -47,12 +53,21 @@ function styleKeys(arrayName: string): string[] {
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
}
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
function themes(): Theme[] {
const start = animSource.indexOf('const ANIM_THEMES = [');
const body = animSource.slice(start, animSource.indexOf('];', start));
return [
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
const parsed = [
...body.matchAll(
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
// Every theme entry must parse: a theme missing a surface (or a regex that
// drifted from the source) would otherwise pass the checks below vacuously.
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
expect(parsed.length).toBeGreaterThan(0);
return parsed;
}
/**
@@ -82,7 +97,7 @@ describe('entrance animation styles', () => {
describe(`${surface.attr} surface`, () => {
it('backs every style with a rule that names a keyframe block that exists', () => {
for (const key of styleKeys(surface.array)) {
if (CSS_LESS_STYLES.has(key)) {
if (isCssLess(surface.attr, key)) {
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
continue;
}
@@ -99,8 +114,16 @@ describe('entrance animation styles', () => {
});
}
it('ships the blur style on all four surfaces', () => {
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
/**
* Tiles are the exception: six frames animate at once, so their styles stay
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
*/
it('ships the blur style on the four single-element surfaces', () => {
for (const surface of SURFACES) {
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
else expect(styleKeys(surface.array)).toContain('blur');
}
});
it('gives every theme an <option> and only styles that exist', () => {
@@ -173,3 +196,96 @@ describe('entrance animation styles', () => {
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
});
describe('tile grid entrance styles', () => {
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
const tileNames = (key: string, scope: RegExp) =>
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
.filter((rule) => scope.test(rule[1]))
.flatMap((rule) =>
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
name: m[1],
onPseudo: rule[1].includes('::before'),
}))
);
/**
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
* A tile fits once at its final size (#464); a box-model property here would
* resize its PTY mid-animation, and a filter on six live terminals at once
* is the frame-time cost the pane's `blur` takes for one.
*/
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
if (key === 'off') continue;
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
for (const { name, onPseudo } of names) {
const body = keyframeBody(name);
expect(body, `@keyframes ${name} missing`).not.toBeNull();
if (onPseudo) continue; // a wash over the tile, no layout of its own
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
}
}
}
});
/**
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
* animationend, and the still copy goes on its last tile's `tile-leave*`:
* a keyframe named otherwise would leave the class on (or the copy up)
* until a backstop timer.
*/
it('names every frame keyframe for the events tile-grid.js listens for', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
}
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
}
}
});
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
expect(timed.length).toBeGreaterThan(0);
for (const key of timed) {
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
}
expect(timed).not.toContain('settle');
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
// The legacy theme (the default) leaves the grid's own motion untouched.
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
});
/**
* App Settings → Appearance → Tile Animations: one option per style, wired
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
* own `settle` first and named as the off default.
*/
it('lists every tile style in the Tile Animations setting, off by default', () => {
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
expect(values[0]).toBe('settle');
expect(select).toContain('<option value="settle">Off (default)</option>');
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
// Nothing new for an install that never picks it: no saved key means settle.
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
});
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
it('plays every terminal pane style on a tile screen too', () => {
for (const key of styleKeys('TERM_ANIM_STYLES')) {
if (CSS_LESS_STYLES.has(key)) continue;
// The body itself, not only its ::before wash.
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
}
});
});
+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);
});
});