mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(tiles): the grid opens and closes with a short animation
Owner request: "when clicking on the tile button first make this animation nicer". Recorded before: the click froze the page, six empty tiles cut in at once, each tile's history then scrolled in visibly as its load landed, and closing showed an empty single view for about a quarter of a second before its own replay scrolled in. The grid's own motion, on by default (not an entrance-animations.js theme, which are off by default): - Opening: each tile fades and settles in (opacity, translateY 6px, scale .97), 180 ms, 24 ms apart in reading order: the last of six is done at 300 ms. Its terminal stays transparent until the load queue reports its first capture done, then fades in whole (160 ms), so no replay scrolls by; a 15 s backstop shows it should that never come. A tile added later enters the same way. - Closing with the toggle (button, Ctrl+Shift+G; owner answer: only these): the close stays synchronous, and a still copy of the tiles (clones: no xterm, socket or listener; inert, aria-hidden, no pointer) dims at once over the stage, holds until the single view's selectSession has replayed its session (at most 700 ms), then fades out. No empty single view between the two. A reopen drops a copy still showing; a web tab hides it. - A re-form to another count fades the old grid's copy out at once while the new tiles enter. - The count menu fades in (140 ms). Every one animates opacity and transform only, so FitAddon measures the final cell and each tile still sends one PTY resize (#464); nothing moves, and no copy is made, under prefers-reduced-motion. Tests (tile-grid-motion.test.ts): the stagger, the reveal and its backstop, the same fits and connects with and without motion, the held copy (released on settle, by its cap, removed on the last tile-leave or its fallback), only the toggle animates, a reopen purges, the zoomed tile alone, the re-form, reduced motion, and a CSS guard that every new keyframe touches only opacity and transform. The vm harness gains style.setProperty, cloneNode, isConnected and lastElementChild. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19817,6 +19817,15 @@ body.tile-grid-resizing--row * {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
transform-origin: top right;
|
||||||
|
animation: tile-count-menu-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-count-menu-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.96);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-count-heading {
|
.tile-count-heading {
|
||||||
@@ -19890,6 +19899,129 @@ body.tile-grid-resizing--row * {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tile grid motion (the grid's own default, not an entrance theme) ──────
|
||||||
|
Opening the grid, each tile fades and settles in, staggered in reading
|
||||||
|
order; its terminal stays transparent until its first capture lands, then
|
||||||
|
fades in whole (no replay scrolling by). Closing it with the Tiles toggle, a
|
||||||
|
still copy of the tiles (tile-grid.js _ghostTileGrid: clones, no
|
||||||
|
terminals) dims at once, stays over the single view until it has its
|
||||||
|
content, then fades. Every one animates opacity and transform only:
|
||||||
|
FitAddon reads the untransformed layout box, so each tile still fits once
|
||||||
|
at its final size and sends one PTY resize (#464). Nothing moves under
|
||||||
|
prefers-reduced-motion. */
|
||||||
|
.tile.tile--entering {
|
||||||
|
animation: tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
animation-delay: calc(var(--tile-enter-index, 0) * 24ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A tile that needs you enters AND pulses (one animation property). */
|
||||||
|
.tile.tile--entering.tile--needs {
|
||||||
|
animation:
|
||||||
|
tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--tile-enter-index, 0) * 24ms) both,
|
||||||
|
tile-needs-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px) scale(0.97);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-body .xterm {
|
||||||
|
transition: opacity 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.tile--revealing .tile-body .xterm {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "Loading…" label breathes while the tile waits for its turn. */
|
||||||
|
.tile.tile--loading .tile-body::after {
|
||||||
|
animation: tile-loading-breathe 1.2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-loading-breathe {
|
||||||
|
from {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-grid-ghosts {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
padding: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A web tab takes the stage at once, the still copy with it. */
|
||||||
|
.main.webview-active .tile-grid-ghosts {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-grid-ghosts .tile.tile--leaving {
|
||||||
|
animation: tile-ghost-dim 150ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving {
|
||||||
|
animation: tile-leave 170ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-grid-ghosts.tile-grid-ghosts--now.tile-grid-ghosts--release .tile.tile--leaving {
|
||||||
|
animation: tile-leave-now 170ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-ghost-dim {
|
||||||
|
to {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave {
|
||||||
|
from {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-now {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tile-count-menu,
|
||||||
|
.tile.tile--entering,
|
||||||
|
.tile.tile--entering.tile--needs,
|
||||||
|
.tile.tile--loading .tile-body::after,
|
||||||
|
.tile-grid-ghosts .tile.tile--leaving,
|
||||||
|
.tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving,
|
||||||
|
.tile-grid-ghosts.tile-grid-ghosts--now.tile-grid-ghosts--release .tile.tile--leaving {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-grid-ghosts {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-body .xterm {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Split-picker menu: a small popover listing sessions to split with, appended
|
/* Split-picker menu: a small popover listing sessions to split with, appended
|
||||||
to document.body and positioned `fixed` by openSplitPicker() (JS sets
|
to document.body and positioned `fixed` by openSplitPicker() (JS sets
|
||||||
top/right against the Split header button's own rect). z-index above the
|
top/right against the Split header button's own rect). z-index above the
|
||||||
|
|||||||
+152
-21
@@ -32,6 +32,16 @@ const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
|
|||||||
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
|
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
|
||||||
// the stored grid above stays ids only.
|
// the stored grid above stays ids only.
|
||||||
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
|
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
|
||||||
|
// The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still
|
||||||
|
// copy of a closing grid goes even if animationend never comes (a hidden tab,
|
||||||
|
// a cancelled animation).
|
||||||
|
const TILE_GHOST_FALLBACK_MS = 450;
|
||||||
|
// The longest a closing grid's still copy waits for the single view's replay
|
||||||
|
// before it fades anyway, so a slow load never leaves a stale picture up.
|
||||||
|
const TILE_GHOST_HOLD_MAX_MS = 700;
|
||||||
|
// A tile's terminal shows once its first capture has landed (the load queue
|
||||||
|
// says so); this is the backstop should that never be reported.
|
||||||
|
const TILE_REVEAL_FALLBACK_MS = 15000;
|
||||||
// Trailing debounce for refitting tiles after the grid area changes size, so a
|
// Trailing debounce for refitting tiles after the grid area changes size, so a
|
||||||
// window drag sends each tile's PTY one resize, not one per frame.
|
// window drag sends each tile's PTY one resize, not one per frame.
|
||||||
const TILE_GRID_REFIT_MS = 150;
|
const TILE_GRID_REFIT_MS = 150;
|
||||||
@@ -194,7 +204,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// A quiet "loading" state on a tile until its capture has landed.
|
// A quiet "loading" state on a tile until its capture has landed.
|
||||||
onChange: (tile, state) => {
|
onChange: (tile, state) => {
|
||||||
const entry = grid.tiles.get(tile.sessionId);
|
const entry = grid.tiles.get(tile.sessionId);
|
||||||
if (entry?.tile === tile) entry.el.classList.toggle('tile--loading', state !== 'idle');
|
if (entry?.tile !== tile) return;
|
||||||
|
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');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -264,6 +278,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// here (a 1000-line serialize and up to 256 KB of localStorage for a
|
// here (a 1000-line serialize and up to 256 KB of localStorage for a
|
||||||
// non-shell session) only ever produced a copy that was thrown away.
|
// non-shell session) only ever produced a copy that was thrown away.
|
||||||
this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) });
|
this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) });
|
||||||
|
// A grid closed a moment ago may still be fading out over the stage.
|
||||||
|
this._purgeTileGhosts();
|
||||||
grid.open = true;
|
grid.open = true;
|
||||||
grid.cells = [];
|
grid.cells = [];
|
||||||
grid.cols = 0;
|
grid.cols = 0;
|
||||||
@@ -274,8 +290,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.hideWelcome();
|
this.hideWelcome();
|
||||||
// Mount every tile and lay the grid out BEFORE any tile connects, so each
|
// Mount every tile and lay the grid out BEFORE any tile connects, so each
|
||||||
// first fit measures its real cell; the focused tile connects first, so
|
// first fit measures its real cell; the focused tile connects first, so
|
||||||
// its capture is the one the queue starts with.
|
// its capture is the one the queue starts with. Each tile enters in
|
||||||
for (const id of wanted) this._mountTile(id);
|
// reading order (opacity and transform only: the fit measures the final
|
||||||
|
// cell, so the animation adds no resize).
|
||||||
|
wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
|
||||||
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
|
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
|
||||||
grid.cells = wanted.filter((id) => grid.tiles.has(id));
|
grid.cells = wanted.filter((id) => grid.tiles.has(id));
|
||||||
this._applyTileLayout();
|
this._applyTileLayout();
|
||||||
@@ -300,17 +318,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
*
|
*
|
||||||
* `keepStored` remembers the grid for one-click return (toggleTileGrid).
|
* `keepStored` remembers the grid for one-click return (toggleTileGrid).
|
||||||
* `reselect` shows the focused session in the single view through a forced
|
* `reselect` shows the focused session in the single view through a forced
|
||||||
* reload; pass false when the caller selects something itself.
|
* reload; pass false when the caller selects something itself. `animate`
|
||||||
|
* (the Tiles toggle only, owner answer 4) leaves a still copy of the tiles
|
||||||
|
* over the stage until the single view has its content (_ghostTileGrid).
|
||||||
*
|
*
|
||||||
* The main terminal's cached content for EVERY tiled id is invalidated: it was
|
* The main terminal's cached content for EVERY tiled id is invalidated: it was
|
||||||
* written before the grid opened, possibly hours ago, and selectSession paints
|
* written before the grid opened, possibly hours ago, and selectSession paints
|
||||||
* a snapshot as its first frame.
|
* a snapshot as its first frame.
|
||||||
*/
|
*/
|
||||||
closeTileGrid({ keepStored = true, reselect = true } = {}) {
|
closeTileGrid({ keepStored = true, reselect = true, animate = false } = {}) {
|
||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
if (!grid?.open) return;
|
if (!grid?.open) return;
|
||||||
const focusedId = grid.focusedId;
|
const focusedId = grid.focusedId;
|
||||||
const ids = grid.ids.slice();
|
const ids = grid.ids.slice();
|
||||||
|
// The close itself stays synchronous (every caller relies on the grid
|
||||||
|
// being gone on return); what the user sees leave is a still copy of the
|
||||||
|
// tiles, dimmed at once, held until the single view has replayed its
|
||||||
|
// session (no empty flash), then faded out over it.
|
||||||
|
const releaseGhosts = animate ? this._ghostTileGrid({ hold: reselect }) : null;
|
||||||
// Remembered (closed) for one-click return, or forgotten.
|
// Remembered (closed) for one-click return, or forgotten.
|
||||||
if (keepStored) this._persistTileGrid({ open: false });
|
if (keepStored) this._persistTileGrid({ open: false });
|
||||||
else this._forgetStoredTileGrid();
|
else this._forgetStoredTileGrid();
|
||||||
@@ -365,7 +390,92 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._updateTileGridButtonState();
|
this._updateTileGridButtonState();
|
||||||
// The tabs drop their .in-tiles marker.
|
// The tabs drop their .in-tiles marker.
|
||||||
this.renderSessionTabs?.();
|
this.renderSessionTabs?.();
|
||||||
if (reselect) this._selectAfterTileGrid(focusedId);
|
if (reselect) {
|
||||||
|
const shown = this._selectAfterTileGrid(focusedId);
|
||||||
|
if (releaseGhosts) Promise.resolve(shown).finally(releaseGhosts);
|
||||||
|
} else {
|
||||||
|
releaseGhosts?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** False under prefers-reduced-motion: the grid then opens and closes at once. */
|
||||||
|
_tileMotionAllowed() {
|
||||||
|
return !window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A still copy of the open grid over the stage, so the tiles leave calmly
|
||||||
|
* while closeTileGrid (or a re-form) tears the real ones down at once.
|
||||||
|
* Clones only: no xterm, socket or listener comes along (the DOM renderer's
|
||||||
|
* rows and their style elements do, so the copy shows the text), and the
|
||||||
|
* layer is inert, hidden from assistive tech and takes no pointer. With
|
||||||
|
* `hold` it dims at once and waits for the returned release() (the single
|
||||||
|
* view has its content), at most TILE_GHOST_HOLD_MAX_MS; without, it fades
|
||||||
|
* out straight away. It removes itself when the fade ends (or after
|
||||||
|
* TILE_GHOST_FALLBACK_MS). Nothing under reduced motion (release is then a
|
||||||
|
* no-op).
|
||||||
|
*
|
||||||
|
* @returns {() => void} release
|
||||||
|
*/
|
||||||
|
_ghostTileGrid({ hold = false } = {}) {
|
||||||
|
const grid = this._tileGrid;
|
||||||
|
const section = document.getElementById('tileGrid');
|
||||||
|
const main = section?.parentElement;
|
||||||
|
const none = () => {};
|
||||||
|
if (!grid?.open || !section || !main || !this._tileMotionAllowed()) return none;
|
||||||
|
this._purgeTileGhosts();
|
||||||
|
const s = section.getBoundingClientRect();
|
||||||
|
const m = main.getBoundingClientRect();
|
||||||
|
if (!(s.width > 0 && s.height > 0)) return none;
|
||||||
|
const layer = document.createElement('div');
|
||||||
|
layer.className = hold ? 'tile-grid-ghosts' : 'tile-grid-ghosts tile-grid-ghosts--now';
|
||||||
|
layer.setAttribute('aria-hidden', 'true');
|
||||||
|
layer.inert = true;
|
||||||
|
layer.style.left = `${s.left - m.left}px`;
|
||||||
|
layer.style.top = `${s.top - m.top}px`;
|
||||||
|
layer.style.width = `${s.width}px`;
|
||||||
|
layer.style.height = `${s.height}px`;
|
||||||
|
layer.style.gridTemplateColumns = section.style.gridTemplateColumns;
|
||||||
|
layer.style.gridTemplateRows = section.style.gridTemplateRows;
|
||||||
|
// 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');
|
||||||
|
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.add('tile--leaving');
|
||||||
|
layer.appendChild(ghost);
|
||||||
|
}
|
||||||
|
main.appendChild(layer);
|
||||||
|
let fallback = null;
|
||||||
|
let holdCap = null;
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(fallback);
|
||||||
|
clearTimeout(holdCap);
|
||||||
|
layer.remove();
|
||||||
|
if (this._tileGhostLayer === layer) this._tileGhostLayer = null;
|
||||||
|
};
|
||||||
|
const release = () => {
|
||||||
|
if (fallback !== null || !layer.isConnected) return;
|
||||||
|
clearTimeout(holdCap);
|
||||||
|
layer.classList.add('tile-grid-ghosts--release');
|
||||||
|
fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
|
||||||
|
};
|
||||||
|
layer.addEventListener('animationend', (e) => {
|
||||||
|
if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
|
||||||
|
});
|
||||||
|
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
|
||||||
|
else release();
|
||||||
|
this._tileGhostLayer = layer;
|
||||||
|
return release;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Drops a still copy still showing (the grid reopened, or another close came). */
|
||||||
|
_purgeTileGhosts() {
|
||||||
|
this._tileGhostLayer?.remove();
|
||||||
|
this._tileGhostLayer = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -629,6 +739,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const leaving = grid.ids.filter((id) => !target.includes(id));
|
const leaving = grid.ids.filter((id) => !target.includes(id));
|
||||||
const joining = target.filter((id) => !grid.tiles.has(id));
|
const joining = target.filter((id) => !grid.tiles.has(id));
|
||||||
if (leaving.length === 0 && joining.length === 0) return;
|
if (leaving.length === 0 && joining.length === 0) return;
|
||||||
|
// The old grid fades out over the new one as it forms.
|
||||||
|
this._ghostTileGrid();
|
||||||
// A divider or header drag in progress measured the old grid.
|
// A divider or header drag in progress measured the old grid.
|
||||||
this._tileDividerDragTeardown?.();
|
this._tileDividerDragTeardown?.();
|
||||||
this._endTileMoveDrag();
|
this._endTileMoveDrag();
|
||||||
@@ -640,7 +752,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
||||||
const staying = grid.ids.filter((id) => grid.tiles.has(id));
|
const staying = grid.ids.filter((id) => grid.tiles.has(id));
|
||||||
const mounted = joining.filter((id) => this._mountTile(id));
|
const mounted = joining.filter((id, k) => this._mountTile(id, { enterIndex: k }));
|
||||||
const { cols, rows } = this._tileShapeFor(grid.tiles.size);
|
const { cols, rows } = this._tileShapeFor(grid.tiles.size);
|
||||||
grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows);
|
grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows);
|
||||||
grid.cols = cols;
|
grid.cols = cols;
|
||||||
@@ -748,7 +860,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
toggleTileGrid() {
|
toggleTileGrid() {
|
||||||
this.closeTileCountMenu();
|
this.closeTileCountMenu();
|
||||||
if (this._tilesOwnTerminal()) {
|
if (this._tilesOwnTerminal()) {
|
||||||
this.closeTileGrid({ keepStored: true, reselect: true });
|
this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.canOpenTileGrid()) return;
|
if (!this.canOpenTileGrid()) return;
|
||||||
@@ -817,10 +929,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// replayed fresh (forceReload drops the stale snapshot and nulls
|
// replayed fresh (forceReload drops the stale snapshot and nulls
|
||||||
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
||||||
// or, if that session is gone, the same fallback as closing the active tab.
|
// or, if that session is gone, the same fallback as closing the active tab.
|
||||||
|
// Returns the selection's promise (it settles once the replay is written),
|
||||||
|
// or undefined for the welcome screen.
|
||||||
_selectAfterTileGrid(sessionId) {
|
_selectAfterTileGrid(sessionId) {
|
||||||
if (sessionId && this.sessions.has(sessionId)) {
|
if (sessionId && this.sessions.has(sessionId)) {
|
||||||
this.selectSession(sessionId, { forceReload: true, auto: true });
|
return this.selectSession(sessionId, { forceReload: true, auto: true });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try {
|
try {
|
||||||
@@ -829,24 +942,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/* Nothing stored. */
|
/* Nothing stored. */
|
||||||
}
|
}
|
||||||
const next = this.sessionOrder.find((id) => this.sessions.has(id));
|
const next = this.sessionOrder.find((id) => this.sessions.has(id));
|
||||||
if (next) {
|
if (next) return this.selectSession(next, { auto: true });
|
||||||
this.selectSession(next, { auto: true });
|
this.terminal?.clear();
|
||||||
} else {
|
this.showWelcome();
|
||||||
this.terminal?.clear();
|
return undefined;
|
||||||
this.showWelcome();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds one session as a tile (open grid only), into `cell` when that cell is
|
* Adds one session as a tile (open grid only), into `cell` when that cell is
|
||||||
* empty (a tab dropped on it), else the first empty cell in reading order
|
* empty (a tab dropped on it), else the first empty cell in reading order
|
||||||
* (_placeTile). Returns whether it was added.
|
* (_placeTile). It enters as the `enterIndex`-th of a staggered group
|
||||||
|
* (_mountTile). Returns whether it was added.
|
||||||
*/
|
*/
|
||||||
addTile(sessionId, { cell = -1 } = {}) {
|
addTile(sessionId, { cell = -1, enterIndex = 0 } = {}) {
|
||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
if (!grid?.open || grid.tiles.has(sessionId)) return false;
|
if (!grid?.open || grid.tiles.has(sessionId)) return false;
|
||||||
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
||||||
if (!this._mountTile(sessionId)) return false;
|
if (!this._mountTile(sessionId, { enterIndex })) return false;
|
||||||
this._placeTile(sessionId, cell);
|
this._placeTile(sessionId, cell);
|
||||||
// A tile added while one is zoomed by hand is meant to be seen.
|
// A tile added while one is zoomed by hand is meant to be seen.
|
||||||
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
||||||
@@ -924,12 +1036,31 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
_mountTile(sessionId) {
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
_mountTile(sessionId, { enterIndex = 0 } = {}) {
|
||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session || this.detachedSessions?.has(sessionId)) return false;
|
if (!session || this.detachedSessions?.has(sessionId)) return false;
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'tile';
|
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);
|
||||||
|
}
|
||||||
el.dataset.sessionId = sessionId;
|
el.dataset.sessionId = sessionId;
|
||||||
// Header and body are siblings: the chrome is refreshed in place
|
// Header and body are siblings: the chrome is refreshed in place
|
||||||
// (_renderTileHeader), never by rewriting the tile, which would take the
|
// (_renderTileHeader), never by rewriting the tile, which would take the
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ export class FakeEl {
|
|||||||
id = '';
|
id = '';
|
||||||
className = '';
|
className = '';
|
||||||
dataset: Record<string, string> = {};
|
dataset: Record<string, string> = {};
|
||||||
style: Record<string, string> = {};
|
/** Inline style; `setProperty` (custom properties) writes into it too. */
|
||||||
|
style: Record<string, string> = Object.defineProperty({} as Record<string, string>, 'setProperty', {
|
||||||
|
value(this: Record<string, string>, name: string, value: string) {
|
||||||
|
this[name] = value;
|
||||||
|
},
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
inert = false;
|
||||||
children: FakeEl[] = [];
|
children: FakeEl[] = [];
|
||||||
parentElement: FakeEl | null = null;
|
parentElement: FakeEl | null = null;
|
||||||
attrs: Record<string, string> = {};
|
attrs: Record<string, string> = {};
|
||||||
@@ -90,6 +97,29 @@ export class FakeEl {
|
|||||||
get firstChild() {
|
get firstChild() {
|
||||||
return this.children[0] ?? null;
|
return this.children[0] ?? null;
|
||||||
}
|
}
|
||||||
|
get lastElementChild() {
|
||||||
|
return this.children.at(-1) ?? null;
|
||||||
|
}
|
||||||
|
/** In the fake document: its ancestors end at `main` or `body`. */
|
||||||
|
get isConnected(): boolean {
|
||||||
|
let n: FakeEl = this;
|
||||||
|
while (n.parentElement) n = n.parentElement;
|
||||||
|
return n === main || n === body;
|
||||||
|
}
|
||||||
|
/** A copy of this element and (deep) its subtree: classes, data, style, attributes, text; no listeners. */
|
||||||
|
cloneNode(deep = false): FakeEl {
|
||||||
|
const copy = new FakeEl();
|
||||||
|
copy.id = this.id;
|
||||||
|
copy.className = this.className;
|
||||||
|
copy.dataset = { ...this.dataset };
|
||||||
|
for (const [k, v] of Object.entries(this.style)) copy.style[k] = v;
|
||||||
|
copy.attrs = { ...this.attrs };
|
||||||
|
copy.textContent = this.textContent;
|
||||||
|
copy.title = this.title;
|
||||||
|
copy.hidden = this.hidden;
|
||||||
|
if (deep) for (const child of this.children) copy.appendChild(child.cloneNode(true));
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
get nextSibling() {
|
get nextSibling() {
|
||||||
const siblings = this.parentElement?.children ?? [];
|
const siblings = this.parentElement?.children ?? [];
|
||||||
return siblings[siblings.indexOf(this) + 1] ?? null;
|
return siblings[siblings.indexOf(this) + 1] ?? null;
|
||||||
@@ -324,6 +354,8 @@ export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
|
|||||||
/** Resets the shared fake DOM and tile registry between tests. */
|
/** Resets the shared fake DOM and tile registry between tests. */
|
||||||
export function resetGridHarness() {
|
export function resetGridHarness() {
|
||||||
FakeTile.all = [];
|
FakeTile.all = [];
|
||||||
|
// A test's prefers-reduced-motion answer (window.matchMedia) goes with it.
|
||||||
|
delete windowStub.matchMedia;
|
||||||
focusedEl = null;
|
focusedEl = null;
|
||||||
idleCallbacks.length = 0;
|
idleCallbacks.length = 0;
|
||||||
rafCallbacks.length = 0;
|
rafCallbacks.length = 0;
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* @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<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);
|
||||||
|
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;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user