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 committed 2026-10-09 17:43:55 +02:00
1 parent 3a0cee6b90
commit decd263b17
10 files changed
+1556 -122

No files matched your search

+92 -21
View File
@@ -237,8 +237,15 @@ Object.assign(CodemanApp.prototype, {
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
entry.el.classList.toggle('tile--loading', state !== 'idle');
// The first capture has landed (or failed): the terminal fades in,
// whole, instead of showing its replay scroll by.
if (state === 'idle') entry.el.classList.remove('tile--revealing');
// whole, instead of showing its replay scroll by, or plays the
// terminal pane's entrance style (entrance-animations.js).
if (state === 'idle') {
entry.el.classList.remove('tile--revealing');
if (entry.screenOwed) {
entry.screenOwed = false;
this.playTileScreenEntrance?.(entry.body);
}
}
},
});
}
@@ -536,14 +543,28 @@ Object.assign(CodemanApp.prototype, {
// Zoomed, only the zoomed tile is on screen (the others are display: none
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
const zoomed = section.classList.contains('tile-grid--zoomed');
const copies = [];
for (const id of grid.ids) {
const el = grid.tiles.get(id)?.el;
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
const ghost = el.cloneNode(true);
ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
ghost.classList.remove(
'tile--entering',
'tile--enter-themed',
'tile--enter-hold',
'tile--needs',
'tile--loading',
'tile--drop-target',
'tile--dragging'
);
ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
ghost.classList.add('tile--leaving');
layer.appendChild(ghost);
copies.push({ ghost, el, sessionId: id });
}
// The entrance style's own way out (entrance-animations.js): back into the
// tabs, a CRT switch-off. Placed while the real tiles are still measurable.
const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
main.appendChild(layer);
let fallback = null;
let holdCap = null;
@@ -557,10 +578,11 @@ Object.assign(CodemanApp.prototype, {
if (fallback !== null || !layer.isConnected) return;
clearTimeout(holdCap);
layer.classList.add('tile-grid-ghosts--release');
fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
};
layer.addEventListener('animationend', (e) => {
if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
if (e.pseudoElement || e.target !== layer.lastElementChild) return;
if (/^tile-leave/.test(e.animationName)) done();
});
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
else release();
@@ -1323,11 +1345,10 @@ Object.assign(CodemanApp.prototype, {
/**
* Builds one tile (header, body, TerminalTile); where it goes is the
* caller's (grid.cells). It enters with a short fade and settle
* (styles.css .tile--entering, the `enterIndex`-th of a staggered group),
* and its terminal stays transparent until its first capture lands
* (.tile--revealing, cleared by the load queue, with a backstop timer).
* Opacity and transform only, never anything its fit reads.
* caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
* group (_beginTileEntrance), and its terminal stays transparent until its
* first capture lands (.tile--revealing, cleared by the load queue, with a
* backstop timer). Opacity and transform only, never anything its fit reads.
*/
_mountTile(sessionId, { enterIndex = 0 } = {}) {
const grid = this._tileGrid;
@@ -1336,16 +1357,11 @@ Object.assign(CodemanApp.prototype, {
const el = document.createElement('div');
el.className = 'tile tile--revealing';
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
if (this._tileMotionAllowed()) {
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
const onEnd = (e) => {
if (e.target !== el || e.animationName !== 'tile-enter') return;
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering');
};
el.addEventListener('animationend', onEnd);
}
// Its screen plays the terminal pane's entrance once its first capture
// lands, when its frame entered in a tile style (Tile Animations): never
// with the default `settle`, nor on a reload's restore, which settles.
const entered = this._beginTileEntrance(el, sessionId, enterIndex);
const screenOwed = !!entered && entered !== 'settle';
el.dataset.sessionId = sessionId;
// Header and body are siblings: the chrome is refreshed in place
// (_renderTileHeader), never by rewriting the tile, which would take the
@@ -1391,12 +1407,61 @@ Object.assign(CodemanApp.prototype, {
renaming: false,
// The pid this tile last saw, so a pane that starts later is noticed.
pid: this.sessions.get(sessionId)?.pid ?? null,
// Its screen plays the terminal entrance once its first capture lands.
screenOwed,
});
// Where it goes is the caller's (grid.cells).
this._renderTileHeader(sessionId);
return true;
},
/**
* A tile's frame enters as the `enterIndex`-th of a staggered group, in the
* entrance style (entrance-animations.js, App Settings → Entrance
* Animations). `settle` is the grid's own fade and settle (styles.css
* .tile--entering), and what a reload restores with whatever the theme:
* nothing animates on page load. Any other style is timed by the entrance
* module a frame later, once the layout is final (_stageTileEntrance).
* Opacity and transform only, never anything the fit reads.
*
* @returns {string|null} the style it enters with, or null when it does not animate
*/
_beginTileEntrance(el, sessionId, enterIndex = 0) {
const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
if (!this._tileMotionAllowed() || style === 'off') return null;
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
let backstop = null;
const finish = () => {
clearTimeout(backstop);
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
};
// The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
// never a child's animation or a wash on its ::before.
const onEnd = (e) => {
if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
finish();
};
el.addEventListener('animationend', onEnd);
el._tileEnterFinish = finish;
if (style !== 'settle') {
this._stageTileEntrance?.(el, sessionId, (ms) => {
clearTimeout(backstop);
backstop = setTimeout(finish, ms);
});
}
return style;
},
/** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
_replayTileEntrance(el, sessionId, enterIndex = 0) {
el._tileEnterFinish?.();
void el.offsetWidth;
return this._beginTileEntrance(el, sessionId, enterIndex);
},
/**
* Makes `el` a drop target for a session tab dragged from the strip (the
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
@@ -2598,7 +2663,13 @@ Object.assign(CodemanApp.prototype, {
if (this._tilesOwnTerminal()) return false;
const stored = this._readStoredTileGrid();
if (!stored?.open || stored.ids.length === 0) return false;
return this._openStoredTileGrid(stored);
// Put back, not opened: the tiles settle in whatever the entrance theme.
this._tileEnterQuiet = true;
try {
return this._openStoredTileGrid(stored);
} finally {
this._tileEnterQuiet = false;
}
},
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */