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:
Codeman maintainer
2026-10-08 04:24:54 +02:00
parent 7debebb7b3
commit 78bfe53dc7
4 changed files with 625 additions and 22 deletions
+33 -1
View File
@@ -22,7 +22,14 @@ export class FakeEl {
id = '';
className = '';
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[] = [];
parentElement: FakeEl | null = null;
attrs: Record<string, string> = {};
@@ -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;