mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Tab Layout "By case" at tablet widths (600 to 767px, getDeviceType's 'tablet'): the case boxes could shrink in the tablet's one-row scrolling strip, so they squeezed and wrapped their tabs inside themselves and every tab past a box's first line was clipped under the fixed 48px header. The boxes now dissolve into the chip row as they already do on phones, which keeps the tablet's own 40px chip geometry. The rule sits in its own 600 to 767px block, not the 768px tablet block, so the desktop path at 768px and up (boxes keep their width, the strip wraps box by box) is untouched. Needs-you tile pulse: the glow animated box-shadow on the tile itself, so the whole tile (DOM-rendered terminal rows included, the whole stage when zoomed) was repainted every frame for as long as a prompt waited. The tile keeps its red border; the glow is now a static inset shadow on a .tile--needs::after overlay (inset because .tile is overflow: hidden and clips an outer one, z-index 3 above .tile-attach, pointer-events none) and only its opacity animates. The entering-and-needs animation shorthand is gone, since it would now blink the whole tile's opacity, and reduced motion keeps the static ring and hides the overlay. Welcome chips: the phone block set 36px, below the 40px that styles.css gives touch screens, and it wins on every phone, so phones got shorter chips than tablets. It now restates 40px. Tests: the tablet widths and the 768px boundary in tab-clusters, the opacity-only pulse overlay in tile-grid-motion, and the phone chip height in run-mode-ui; each fails against the previous CSS. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
/**
|
|
* @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 postcss, { type AtRule, type Rule } from 'postcss';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
FakeEl,
|
|
FakeTile,
|
|
body,
|
|
bySelector,
|
|
flushFrames,
|
|
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);
|
|
flushFrames();
|
|
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',
|
|
'tile-needs-pulse',
|
|
]) {
|
|
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--needs::after',
|
|
'.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;/);
|
|
});
|
|
|
|
it('the needs-you pulse fades a static glow overlay in and out, never repainting the tile itself', () => {
|
|
// A needs tile pulses for as long as its prompt waits, hours at a time. A
|
|
// box-shadow animated on the tile repainted the whole tile (its DOM-rendered
|
|
// terminal rows with it, the whole stage when zoomed) every frame.
|
|
const rules: Array<{ selector: string; media: string; decls: Record<string, string> }> = [];
|
|
postcss.parse(css).walkRules((rule: Rule) => {
|
|
if (!rule.selector.includes('tile--needs')) return;
|
|
const decls: Record<string, string> = {};
|
|
rule.walkDecls((d) => {
|
|
decls[d.prop] = d.value;
|
|
});
|
|
const media = rule.parent?.type === 'atrule' ? (rule.parent as AtRule).params : '';
|
|
for (const selector of rule.selectors) rules.push({ selector, media, decls });
|
|
});
|
|
const find = (selector: string, media = '') => rules.filter((r) => r.selector === selector && r.media === media);
|
|
|
|
// The tile keeps its red border and animates nothing.
|
|
const [tile] = find('.tile.tile--needs');
|
|
expect(tile.decls['border-color']).toContain('var(--red');
|
|
expect(tile.decls.animation).toBeUndefined();
|
|
expect(tile.decls['box-shadow']).toBeUndefined();
|
|
|
|
// The glow is an overlay: a STATIC inset shadow (.tile is overflow: hidden
|
|
// and clips an outer one), above the Attach overlay, out of the pointer's way.
|
|
const [glow] = find('.tile.tile--needs::after');
|
|
expect(glow.decls.content).toBe("''");
|
|
expect(glow.decls.position).toBe('absolute');
|
|
expect(glow.decls.inset).toBe('0');
|
|
expect(glow.decls['pointer-events']).toBe('none');
|
|
expect(Number(glow.decls['z-index'])).toBeGreaterThan(2);
|
|
expect(glow.decls['box-shadow']).toMatch(/^inset /);
|
|
expect(glow.decls.animation).toMatch(/^tile-needs-pulse /);
|
|
|
|
// Only that overlay runs the pulse: no rule restates it on the tile (the old
|
|
// entering-and-needs shorthand would now blink the whole tile's opacity).
|
|
const pulsing = rules.filter((r) => /tile-needs-pulse/.test(r.decls.animation ?? ''));
|
|
expect(pulsing.map((r) => r.selector)).toEqual(['.tile.tile--needs::after']);
|
|
expect(rules.some((r) => r.selector.includes('tile--entering') && r.selector.includes('tile--needs'))).toBe(false);
|
|
|
|
// Reduced motion: no pulse, and a static ring on the tile instead.
|
|
const reduce = '(prefers-reduced-motion: reduce)';
|
|
expect(find('.tile.tile--needs::after', reduce).some((r) => r.decls.display === 'none')).toBe(true);
|
|
expect(find('.tile.tile--needs', reduce).some((r) => /^0 0 0 2px /.test(r.decls['box-shadow'] ?? ''))).toBe(true);
|
|
});
|
|
});
|