diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8f5281cf..1afe5454 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19817,6 +19817,15 @@ body.tile-grid-resizing--row * { border-radius: 10px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25); 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 { @@ -19890,6 +19899,129 @@ body.tile-grid-resizing--row * { 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 to document.body and positioned `fixed` by openSplitPicker() (JS sets top/right against the Split header button's own rect). z-index above the diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index b8985e7d..1a954439 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -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 // the stored grid above stays ids only. 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 // window drag sends each tile's PTY one resize, not one per frame. 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. onChange: (tile, state) => { 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 // non-shell session) only ever produced a copy that was thrown away. 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.cells = []; grid.cols = 0; @@ -274,8 +290,10 @@ Object.assign(CodemanApp.prototype, { this.hideWelcome(); // 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 - // its capture is the one the queue starts with. - for (const id of wanted) this._mountTile(id); + // its capture is the one the queue starts with. Each tile enters in + // 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). grid.cells = wanted.filter((id) => grid.tiles.has(id)); this._applyTileLayout(); @@ -300,17 +318,24 @@ Object.assign(CodemanApp.prototype, { * * `keepStored` remembers the grid for one-click return (toggleTileGrid). * `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 * written before the grid opened, possibly hours ago, and selectSession paints * a snapshot as its first frame. */ - closeTileGrid({ keepStored = true, reselect = true } = {}) { + closeTileGrid({ keepStored = true, reselect = true, animate = false } = {}) { const grid = this._tileGrid; if (!grid?.open) return; const focusedId = grid.focusedId; 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. if (keepStored) this._persistTileGrid({ open: false }); else this._forgetStoredTileGrid(); @@ -365,7 +390,92 @@ Object.assign(CodemanApp.prototype, { this._updateTileGridButtonState(); // The tabs drop their .in-tiles marker. 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 joining = target.filter((id) => !grid.tiles.has(id)); 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. this._tileDividerDragTeardown?.(); this._endTileMoveDrag(); @@ -640,7 +752,7 @@ Object.assign(CodemanApp.prototype, { } if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; 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); grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows); grid.cols = cols; @@ -748,7 +860,7 @@ Object.assign(CodemanApp.prototype, { toggleTileGrid() { this.closeTileCountMenu(); if (this._tilesOwnTerminal()) { - this.closeTileGrid({ keepStored: true, reselect: true }); + this.closeTileGrid({ keepStored: true, reselect: true, animate: true }); return; } if (!this.canOpenTileGrid()) return; @@ -817,10 +929,11 @@ Object.assign(CodemanApp.prototype, { // replayed fresh (forceReload drops the stale snapshot and nulls // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), // 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) { if (sessionId && this.sessions.has(sessionId)) { - this.selectSession(sessionId, { forceReload: true, auto: true }); - return; + return this.selectSession(sessionId, { forceReload: true, auto: true }); } this.activeSessionId = null; try { @@ -829,24 +942,23 @@ Object.assign(CodemanApp.prototype, { /* Nothing stored. */ } const next = this.sessionOrder.find((id) => this.sessions.has(id)); - if (next) { - this.selectSession(next, { auto: true }); - } else { - this.terminal?.clear(); - this.showWelcome(); - } + if (next) return this.selectSession(next, { auto: true }); + this.terminal?.clear(); + this.showWelcome(); + return undefined; }, /** * 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 - * (_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; if (!grid?.open || grid.tiles.has(sessionId)) 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); // A tile added while one is zoomed by hand is meant to be seen. if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; @@ -924,12 +1036,31 @@ Object.assign(CodemanApp.prototype, { 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 session = this.sessions.get(sessionId); if (!session || this.detachedSessions?.has(sessionId)) return false; 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; // Header and body are siblings: the chrome is refreshed in place // (_renderTileHeader), never by rewriting the tile, which would take the diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index ba61f63f..9e95b7df 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -22,7 +22,14 @@ export class FakeEl { id = ''; className = ''; dataset: Record = {}; - style: Record = {}; + /** Inline style; `setProperty` (custom properties) writes into it too. */ + style: Record = Object.defineProperty({} as Record, 'setProperty', { + value(this: Record, name: string, value: string) { + this[name] = value; + }, + enumerable: false, + }); + inert = false; children: FakeEl[] = []; parentElement: FakeEl | null = null; attrs: Record = {}; @@ -90,6 +97,29 @@ export class FakeEl { get firstChild() { 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() { const siblings = this.parentElement?.children ?? []; 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. */ export function resetGridHarness() { FakeTile.all = []; + // A test's prefers-reduced-motion answer (window.matchMedia) goes with it. + delete windowStub.matchMedia; focusedEl = null; idleCallbacks.length = 0; rafCallbacks.length = 0; diff --git a/test/tile-grid-motion.test.ts b/test/tile-grid-motion.test.ts new file mode 100644 index 00000000..fd331ffe --- /dev/null +++ b/test/tile-grid-motion.test.ts @@ -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) { + 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((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(() => {})); + 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(() => {})); + 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;/); + }); +});