diff --git a/frontend/src/v2/__tests__/ConnectorDrawer.motion.test.tsx b/frontend/src/v2/__tests__/ConnectorDrawer.motion.test.tsx
new file mode 100644
index 0000000000..6d170f080b
--- /dev/null
+++ b/frontend/src/v2/__tests__/ConnectorDrawer.motion.test.tsx
@@ -0,0 +1,103 @@
+// @ts-nocheck
+// The connector drawer slides in from the right and back out (the motion spec):
+// on close it stays mounted, inert, until its exit animation ends, and it can
+// never be stranded there.
+import React from 'react';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { mockApi, renderPage, findDrawer, clickTile } from '../__testkit__/connectorsKit';
+import { EXIT_SAFETY_MS } from '../utils/motion';
+
+jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory());
+
+const pods = [{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }];
+const liveSlack = {
+ installableId: 'slack', label: 'Slack', description: 'Slack copy.', available: true, offered: true,
+ installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: 'p1', components: [] },
+ integration: {
+ _id: 'i-live', installationId: 'install-slack-u1', type: 'slack', status: 'connected', scope: 'user', isActive: true,
+ createdAt: new Date(Date.now() - 3_600_000).toISOString(), updatedAt: new Date().toISOString(),
+ config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} }, podId: 'p1',
+ },
+};
+
+const realMatchMedia = window.matchMedia;
+const realGetComputedStyle = window.getComputedStyle;
+let reduce = false;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ reduce = false;
+ window.history.replaceState({}, '', '/v2/connectors');
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: query.includes('reduced-motion') ? reduce : false,
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+});
+
+afterEach(() => {
+ window.matchMedia = realMatchMedia;
+ window.getComputedStyle = realGetComputedStyle;
+});
+
+// jsdom runs no animations; report the ones connectors.css would apply.
+const withDrawerAnimations = () => {
+ window.getComputedStyle = (node, pseudo) => new Proxy(realGetComputedStyle(node, pseudo), {
+ get(target, key) {
+ if (key === 'animationName' && node.classList?.contains('v2-cx-drawer')) {
+ return node.classList.contains('is-leaving') ? 'v2-cx-drawer-out' : 'v2-cx-drawer-in';
+ }
+ const value = target[key];
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ });
+};
+
+const openSlack = async () => {
+ mockApi({ installables: [liveSlack], pods });
+ renderPage();
+ await clickTile('Slack');
+ return findDrawer('Slack');
+};
+const drawerNode = () => document.querySelector('section.v2-cx-drawer');
+
+describe('connector drawer motion', () => {
+ it('opens without the leaving state and, on close, stays mounted and inert until its slide out ends', async () => {
+ withDrawerAnimations();
+ const drawer = await openSlack();
+ expect(drawer).not.toHaveClass('is-leaving');
+ expect(drawer).not.toHaveAttribute('inert');
+
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(drawerNode()).toHaveClass('is-leaving');
+ expect(drawerNode()).toHaveAttribute('inert');
+ // Leaving, it is out of the accessibility tree: the page reads as closed.
+ expect(screen.queryByRole('region', { name: 'Slack' })).toBeNull();
+
+ fireEvent.animationEnd(drawerNode());
+ expect(drawerNode()).toBeNull();
+ });
+
+ it('unmounts after the safety net when no animationend arrives', async () => {
+ withDrawerAnimations();
+ const drawer = await openSlack();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(drawerNode()).not.toBeNull();
+ await waitFor(() => expect(drawerNode()).toBeNull(), { timeout: EXIT_SAFETY_MS + 500 });
+ });
+
+ it('closes at once under reduced motion', async () => {
+ withDrawerAnimations();
+ reduce = true;
+ const drawer = await openSlack();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(drawerNode()).toBeNull();
+ });
+
+ it('closes at once when no exit animation applies at all', async () => {
+ const drawer = await openSlack();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(drawerNode()).toBeNull();
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2ChatMotion.test.tsx b/frontend/src/v2/__tests__/V2ChatMotion.test.tsx
new file mode 100644
index 0000000000..8f7a645fc4
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2ChatMotion.test.tsx
@@ -0,0 +1,242 @@
+// @ts-nocheck
+import React from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { useV2PodDetail } from '../hooks/useV2PodDetail';
+import V2MessageRow, { CollapsiblePre, CollapsibleProse, LIVE_ENTER_WINDOW_MS } from '../components/V2MessageRow';
+import V2ThreadMessages from '../components/V2ThreadMessages';
+import { buildThreadView } from '../utils/threadView';
+
+// Chat motion: a live arrival enters, history appears at once, Show more folds
+// by class, and the content that replaces the loader fades in once.
+
+const mockApi = { get: jest.fn(), post: jest.fn(), patch: jest.fn(), del: jest.fn() };
+const mockSocket = { on: jest.fn(), off: jest.fn() };
+
+jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi }));
+jest.mock('../../context/SocketContext', () => ({
+ useSocket: () => ({ socket: mockSocket, connected: true, joinPod: jest.fn(), leavePod: jest.fn() }),
+}));
+jest.mock('../../context/AuthContext', () => ({
+ useAuth: () => ({ currentUser: { _id: 'human-1', username: 'viewer' } }),
+}));
+
+const message = (id, minute, extra = {}) => ({
+ id,
+ pod_id: 'pod-1',
+ user_id: 'agent-1',
+ content: `text ${id}`,
+ message_type: 'text',
+ created_at: new Date(Date.UTC(2026, 9, 19, 12, minute)).toISOString(),
+ user: { username: 'scout' },
+ ...extra,
+});
+
+const latestNewMessageHandler = () => mockSocket.on.mock.calls
+ .filter(([event]) => event === 'newMessage')
+ .at(-1)[1];
+
+// The real hook feeding real rows, so the test exercises the live path the
+// socket takes rather than a hand-set flag.
+const Transcript = () => {
+ const { messages } = useV2PodDetail('pod-1');
+ return {messages.map((m) => )};
+};
+
+const row = (id) => document.getElementById(`message-${id}`);
+
+describe('chat motion: a live message enters, history does not', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockApi.get.mockImplementation((url) => {
+ if (url === '/api/messages/pod-1?limit=50') return Promise.resolve([message('history', 0)]);
+ if (url.startsWith('/api/messages/pod-1?limit=50&before=')) return Promise.resolve([message('older', -5)]);
+ if (url.includes('/agents')) return Promise.resolve({ agents: [] });
+ return Promise.resolve({ _id: 'pod-1', name: 'Pod', members: [] });
+ });
+ });
+
+ it('gives the enter class to a socket arrival and never to the opened history', async () => {
+ render();
+ await waitFor(() => expect(row('history')).not.toBeNull());
+ expect(row('history')).not.toHaveClass('v2-msg--enter');
+
+ act(() => { latestNewMessageHandler()(message('live', 1)); });
+ expect(row('live')).toHaveClass('v2-msg--enter');
+ expect(row('history')).not.toHaveClass('v2-msg--enter');
+ });
+
+ it('does not animate a socket copy of a row that is already on screen', async () => {
+ render();
+ await waitFor(() => expect(row('history')).not.toBeNull());
+ const before = row('history');
+ act(() => { latestNewMessageHandler()(message('history', 0)); });
+ expect(row('history')).toBe(before);
+ expect(row('history')).not.toHaveClass('v2-msg--enter');
+ });
+
+ it('marks only live arrivals in the hook; history and older pages carry no flag', async () => {
+ let detail;
+ const Probe = () => { detail = useV2PodDetail('pod-1'); return null; };
+ render();
+ await waitFor(() => expect(detail.initialLoadComplete).toBe(true));
+ act(() => { latestNewMessageHandler()(message('live', 1)); });
+ await act(async () => { await detail.loadOlder(); });
+ const flags = Object.fromEntries(detail.messages.map((m) => [m.id, typeof m.liveArrivedAt === 'number']));
+ expect(flags).toEqual({ older: false, history: false, live: true });
+ });
+
+ it('marks your own sent message live, so it gets the same 4px enter and nothing more', async () => {
+ mockApi.post.mockResolvedValue(message('mine', 2, { user_id: 'human-1', user: { username: 'viewer' } }));
+ let detail;
+ const Probe = () => { detail = useV2PodDetail('pod-1'); return null; };
+ render();
+ await waitFor(() => expect(detail.initialLoadComplete).toBe(true));
+ await act(async () => { await detail.sendMessage('hello'); });
+ expect(typeof detail.messages.find((m) => m.id === 'mine').liveArrivedAt).toBe('number');
+ });
+});
+
+describe('chat motion: the row decides once, at mount', () => {
+ const renderRow = (m) => render();
+
+ it('a re-render that drops the live flag keeps the row as it is and does not restart the enter', () => {
+ const live = message('a', 1, { liveArrivedAt: Date.now() });
+ const view = renderRow(live);
+ const node = row('a');
+ expect(node).toHaveClass('v2-msg--enter');
+ view.rerender();
+ expect(row('a')).toBe(node);
+ expect(node).toHaveClass('v2-msg--enter');
+ });
+
+ it('a history row that later gains a flag does not start entering', () => {
+ const history = message('b', 1);
+ const view = renderRow(history);
+ view.rerender();
+ expect(row('b')).not.toHaveClass('v2-msg--enter');
+ });
+
+ it('a row that remounts after the enter window (a root moving into a thread) does not replay', () => {
+ renderRow(message('c', 1, { liveArrivedAt: Date.now() - LIVE_ENTER_WINDOW_MS - 1 }));
+ expect(row('c')).not.toHaveClass('v2-msg--enter');
+ });
+});
+
+describe('chat motion: Show more folds by class, never by measured height', () => {
+ const setScrollHeight = (px) => {
+ Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get: () => px });
+ };
+ afterEach(() => setScrollHeight(0));
+
+ it('a folding prose keeps its fold class open and closed, toggles only the closed class, and sets no inline size', () => {
+ setScrollHeight(900);
+ const { container } = render(
+
long reply
,
+ );
+ const wrapper = container.querySelector('.v2-msg__prose');
+ expect(wrapper).toHaveClass('v2-msg__prose--fold', 'v2-msg__prose--closed');
+ const button = screen.getByRole('button', { name: 'Show more' });
+
+ fireEvent.click(button);
+ expect(wrapper).toHaveClass('v2-msg__prose--fold');
+ expect(wrapper).not.toHaveClass('v2-msg__prose--closed');
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+
+ fireEvent.click(button);
+ expect(wrapper).toHaveClass('v2-msg__prose--fold', 'v2-msg__prose--closed');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ // The motion is CSS sizing to auto; JS never writes a height.
+ expect(wrapper.getAttribute('style')).toBeNull();
+ });
+
+ it('a short prose gets no fold class, so it never transitions', () => {
+ setScrollHeight(60);
+ const { container } = render(
+
short
,
+ );
+ expect(container.querySelector('.v2-msg__prose')).not.toHaveClass('v2-msg__prose--fold');
+ });
+
+ it('long code opens and closes by the collapse classes', () => {
+ const code = Array.from({ length: 10 }, (_, i) => `line ${i}`).join('\n');
+ const { container } = render({`${code}\n`});
+ const box = container.querySelector('.v2-msg__collapse');
+ expect(box).toHaveClass('v2-msg__collapse--closed');
+ fireEvent.click(screen.getByRole('button', { expanded: false }));
+ expect(box).toHaveClass('v2-msg__collapse--open');
+ fireEvent.click(screen.getByRole('button', { expanded: true }));
+ expect(box).toHaveClass('v2-msg__collapse--closed');
+ expect(container.querySelector('pre').getAttribute('style')).toBeNull();
+ });
+});
+
+describe('chat motion: content replacing the loader fades in once', () => {
+ const renderTranscript = (props) => {
+ const threadView = buildThreadView(props.messages, new Map());
+ return (
+
+
+
+ );
+ };
+ const scroller = (container) => container.querySelector('.v2-chat__messages');
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('a load under 300ms shows no spinner, and its content appears without a fade', () => {
+ jest.useFakeTimers();
+ const view = render(renderTranscript({ messages: [], loading: true }));
+ act(() => { jest.advanceTimersByTime(299); });
+ expect(view.container.querySelector('.v2-spinner')).toBeNull();
+ view.rerender(renderTranscript({ messages: [message('h', 0)], loading: false }));
+ act(() => { jest.advanceTimersByTime(400); });
+ expect(view.container.querySelector('.v2-spinner')).toBeNull();
+ expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal');
+ });
+
+ it('fades the transcript in when it replaces the spinner, and a later render does not restart it', () => {
+ jest.useFakeTimers();
+ const view = render(renderTranscript({ messages: [], loading: true }));
+ expect(view.container.querySelector('.v2-spinner')).toBeNull();
+ act(() => { jest.advanceTimersByTime(300); });
+ expect(view.container.querySelector('.v2-spinner')).not.toBeNull();
+ expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal');
+
+ view.rerender(renderTranscript({ messages: [message('h', 0)], loading: false }));
+ expect(view.container.querySelector('.v2-spinner')).toBeNull();
+ expect(scroller(view.container)).toHaveClass('v2-chat__messages--reveal');
+
+ // A re-render keeps the same class on the same node, so the animation does not restart.
+ const node = scroller(view.container);
+ view.rerender(renderTranscript({ messages: [message('h', 0), message('n', 1)], loading: false }));
+ expect(scroller(view.container)).toBe(node);
+ expect(node).toHaveClass('v2-chat__messages--reveal');
+ });
+
+ it('content that never waited on a spinner appears at once', () => {
+ const view = render(renderTranscript({ messages: [message('h', 0)], loading: false }));
+ expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal');
+ // A refresh with rows already on screen shows no spinner, so nothing fades.
+ view.rerender(renderTranscript({ messages: [message('h', 0)], loading: true }));
+ view.rerender(renderTranscript({ messages: [message('h', 0)], loading: false }));
+ expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal');
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2ClaimRun.test.tsx b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx
index a21770a583..4f2f47fb2d 100644
--- a/frontend/src/v2/__tests__/V2ClaimRun.test.tsx
+++ b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { act, render, screen, within } from '@testing-library/react';
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import i18n from '../../i18n';
@@ -75,6 +75,11 @@ describe('claim run summary control (TASK-692)', () => {
expect(claimRowTaskIds(container)).toEqual(['TASK-674', 'TASK-675', 'TASK-676', 'TASK-678']);
act(() => toggle.click());
expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ // The rows stay mounted, inert, while the fold closes, then leave.
+ const closing = container.querySelector('.v2-claim-run .v2-fold') as Element;
+ expect(closing).toHaveClass('v2-fold--closing');
+ expect(closing).toHaveAttribute('inert');
+ fireEvent.animationEnd(closing);
expect(claimRowTaskIds(container)).toEqual([]);
});
@@ -88,6 +93,7 @@ describe('claim run summary control (TASK-692)', () => {
expect(claimRowTaskIds(container)).toHaveLength(4);
await user.keyboard(' ');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.animationEnd(container.querySelector('.v2-claim-run .v2-fold') as Element);
expect(claimRowTaskIds(container)).toEqual([]);
});
diff --git a/frontend/src/v2/__tests__/V2Fold.test.tsx b/frontend/src/v2/__tests__/V2Fold.test.tsx
new file mode 100644
index 0000000000..0081ace889
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2Fold.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import V2Fold from '../components/V2Fold';
+
+const fold = () => screen.queryByText('Body')?.closest('.v2-fold');
+
+describe('V2Fold', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('a fold that starts open renders without an animation class', () => {
+ render(Body);
+ expect(fold()).toHaveClass('v2-fold');
+ expect(fold()).not.toHaveClass('v2-fold--opening');
+ });
+
+ test('a fold that starts closed renders nothing', () => {
+ render(Body);
+ expect(screen.queryByText('Body')).not.toBeInTheDocument();
+ });
+
+ test('opening mounts the body with the opening class until its animation ends', () => {
+ const { rerender } = render(Body);
+ rerender(Body);
+ expect(fold()).toHaveClass('v2-fold--opening');
+ expect(screen.getByText('Body')).toHaveAttribute('id', 'b');
+ expect(screen.getByText('Body')).toHaveClass('x');
+ // The caller's box sits inside the clipping inner, so its padding folds away too.
+ expect(screen.getByText('Body').parentElement).toHaveClass('v2-fold__inner');
+ fireEvent.animationEnd(fold() as Element);
+ expect(fold()).not.toHaveClass('v2-fold--opening');
+ });
+
+ test('closing keeps the body mounted and inert until the close animation ends', () => {
+ const { rerender } = render(Body);
+ rerender(Body);
+ expect(fold()).toHaveClass('v2-fold--closing');
+ expect(fold()).toHaveAttribute('inert');
+ fireEvent.animationEnd(fold() as Element);
+ expect(screen.queryByText('Body')).not.toBeInTheDocument();
+ });
+
+ test('an animation event from inside the body does not close the fold early', () => {
+ const { rerender } = render(Body);
+ rerender(Body);
+ fireEvent.animationEnd(screen.getByText('Body'));
+ expect(screen.getByText('Body')).toBeInTheDocument();
+ });
+
+ test('a closing fold unmounts on a timer when no animation event comes', () => {
+ jest.useFakeTimers();
+ const { rerender } = render(Body);
+ rerender(Body);
+ expect(screen.getByText('Body')).toBeInTheDocument();
+ act(() => { jest.advanceTimersByTime(200); });
+ expect(screen.queryByText('Body')).not.toBeInTheDocument();
+ });
+
+ test('reopening while closing cancels the close', () => {
+ jest.useFakeTimers();
+ const { rerender } = render(Body);
+ rerender(Body);
+ rerender(Body);
+ act(() => { jest.advanceTimersByTime(400); });
+ expect(fold()).toHaveClass('v2-fold--opening');
+ expect(fold()).not.toHaveAttribute('inert');
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2Inspector.test.tsx b/frontend/src/v2/__tests__/V2Inspector.test.tsx
index 3359e2d2ff..04cd48aa3d 100644
--- a/frontend/src/v2/__tests__/V2Inspector.test.tsx
+++ b/frontend/src/v2/__tests__/V2Inspector.test.tsx
@@ -93,6 +93,10 @@ describe('V2Inspector', () => {
expect(await screen.findByText('Slack default mode')).toBeInTheDocument();
expect(JSON.parse(sessionStorage.getItem(INSPECTOR_OPEN_KEY) || '[]')).toEqual(['join', 'needs-you']);
fireEvent.click(folds[0]);
+ // The group body closes by size, inert, then unmounts when its animation ends.
+ const closing = screen.getByText('Slack default mode').closest('.v2-fold') as Element;
+ expect(closing).toHaveClass('v2-fold--closing');
+ fireEvent.animationEnd(closing);
expect(screen.queryByText('Slack default mode')).not.toBeInTheDocument();
});
diff --git a/frontend/src/v2/__tests__/V2PodBoard.motion.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.motion.test.tsx
new file mode 100644
index 0000000000..40acc54cf0
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2PodBoard.motion.test.tsx
@@ -0,0 +1,318 @@
+// @ts-nocheck
+// The board's motion moments from the motion spec: a live column change
+// glides through a view transition at 1440, fades out of the open tab at 390, and
+// the task drawer stays mounted until its slide out ends.
+import React from 'react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import V2PodBoard from '../components/V2PodBoard';
+import { AuthContext } from '../../context/AuthContext';
+import { EXIT_SAFETY_MS, viewTransitionName } from '../utils/motion';
+
+let mockSocketValue = { socket: null, connected: false, joinPod: jest.fn(), leavePod: jest.fn() };
+jest.mock('../../context/SocketContext', () => ({
+ useSocket: () => mockSocketValue,
+}));
+
+jest.mock('axios', () => {
+ const mock = {
+ get: jest.fn(),
+ post: jest.fn(() => Promise.resolve({ data: {} })),
+ patch: jest.fn(() => Promise.resolve({ data: {} })),
+ delete: jest.fn(),
+ defaults: { baseURL: '', headers: { common: {} } },
+ interceptors: {
+ request: { use: jest.fn(), eject: jest.fn() },
+ response: { use: jest.fn(), eject: jest.fn() },
+ },
+ };
+ return { __esModule: true, default: mock, ...mock };
+});
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const axios = require('axios').default;
+
+const authValue = {
+ currentUser: { _id: 'u1', username: 'alice' },
+ user: { _id: 'u1', username: 'alice' },
+ token: 't',
+ loading: false,
+ error: null,
+ isAuthenticated: true,
+ register: jest.fn(),
+ login: jest.fn(),
+ logout: jest.fn(),
+ updateProfile: jest.fn(),
+};
+
+const PENDING = [
+ { taskId: 'TASK-1', title: 'Claim me', status: 'pending', updates: [] },
+ { taskId: 'TASK-2', title: 'Stay put', status: 'pending', updates: [] },
+];
+const CLAIMED = [
+ { ...PENDING[0], status: 'claimed', claimedBy: 'u1', claimExpiresAt: new Date(Date.now() + 30 * 60_000).toISOString() },
+ PENDING[1],
+];
+
+let taskRows = PENDING;
+const wireAxios = () => {
+ axios.get.mockImplementation((url) => {
+ if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks: taskRows, permissions: { canWrite: true } } });
+ if (url.startsWith('/api/pods/')) return Promise.resolve({ data: { name: 'Pod', members: [{ _id: 'u1', username: 'alice' }] } });
+ return Promise.resolve({ data: {} });
+ });
+};
+
+let media = { phone: false, reduce: false };
+const realMatchMedia = window.matchMedia;
+const realGetComputedStyle = window.getComputedStyle;
+
+const handlers = {};
+const liveSocket = () => {
+ mockSocketValue = {
+ socket: { on: (event, fn) => { handlers[event] = fn; }, off: jest.fn(), emit: jest.fn() },
+ connected: true,
+ joinPod: jest.fn(),
+ leavePod: jest.fn(),
+ };
+};
+
+const renderBoard = () => render(
+
+
+
+ } />
+
+
+ ,
+);
+
+const column = (name) => screen.getByRole('region', { name });
+const liveUpdate = async (rows) => {
+ taskRows = rows;
+ await act(async () => { handlers.task_updated({ podId: 'pod-1' }); });
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ taskRows = PENDING;
+ media = { phone: false, reduce: false };
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: query.includes('reduced-motion') ? media.reduce : query.includes('max-width') ? media.phone : false,
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+ liveSocket();
+ wireAxios();
+ delete document.startViewTransition;
+ document.documentElement.classList.remove('v2-board-glide');
+});
+
+afterEach(() => {
+ window.matchMedia = realMatchMedia;
+ window.getComputedStyle = realGetComputedStyle;
+ delete document.startViewTransition;
+});
+
+const stubViewTransitions = () => {
+ const seen = [];
+ document.startViewTransition = jest.fn((update) => {
+ // What the browser captures as the old state: the glide class and the named columns.
+ seen.push({
+ glide: document.documentElement.classList.contains('v2-board-glide'),
+ named: Array.from(document.querySelectorAll('.v2-board__col[data-glide]')).map((node) => node.getAttribute('aria-label')),
+ });
+ update();
+ return { finished: Promise.resolve() };
+ });
+ return seen;
+};
+
+describe('board motion: an agent claims a task', () => {
+ test('each card carries a CSS-safe task- view-transition name', async () => {
+ taskRows = [{ taskId: 'odd id/1.2_x', title: 'Odd', status: 'pending', updates: [] }];
+ renderBoard();
+ const card = (await screen.findByText('Odd')).closest('article');
+ expect(card.style.getPropertyValue('--v2-task-vt')).toBe(viewTransitionName('task', 'odd id/1.2_x'));
+ expect(viewTransitionName('task', 'odd id/1.2_x')).toMatch(/^task-[A-Za-z0-9_-]+$/);
+ // Escaping the underscore keeps two ids from sharing a name.
+ expect(viewTransitionName('task', 'a_2e_')).not.toBe(viewTransitionName('task', 'a.'));
+ });
+
+ test('a live column change at 1440 runs inside startViewTransition and the card lands in its new column', async () => {
+ const seen = stubViewTransitions();
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me');
+ expect(document.startViewTransition).not.toHaveBeenCalled();
+
+ await liveUpdate(CLAIMED);
+
+ expect(document.startViewTransition).toHaveBeenCalledTimes(1);
+ expect(seen[0].glide).toBe(true);
+ expect(seen[0].named.sort()).toEqual(['In Progress', 'Pending']);
+ expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument();
+ expect(within(column('Pending')).queryByText('Claim me')).toBeNull();
+ // Once it has landed the class goes, so the lease ring starts pulsing.
+ await waitFor(() => expect(document.documentElement.classList.contains('v2-board-glide')).toBe(false));
+ expect(document.querySelector('.v2-board__col[data-glide]')).toBeNull();
+ });
+
+ test('an edit that lands while a glide waits is not overwritten by the older rows', async () => {
+ let pending = null;
+ document.startViewTransition = jest.fn((update) => { pending = update; return { finished: Promise.resolve() }; });
+ renderBoard();
+ fireEvent.click(await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Stay put'));
+ const priority = await screen.findByLabelText(/priority/i);
+
+ await liveUpdate(CLAIMED);
+ expect(pending).not.toBeNull();
+
+ // The edit resolves inside the gap; the server now holds P0 for TASK-2.
+ taskRows = [CLAIMED[0], { ...PENDING[1], priority: 'P0' }];
+ axios.patch.mockResolvedValueOnce({ data: { task: { ...PENDING[1], priority: 'P0', updatedAt: new Date().toISOString() } } });
+ await act(async () => { fireEvent.change(priority, { target: { value: 'P0' } }); });
+
+ await act(async () => { pending(); });
+ await waitFor(() => expect(screen.getByLabelText(/priority/i)).toHaveValue('P0'));
+ });
+
+ test('no glide under reduced motion: the card is simply in its new column', async () => {
+ stubViewTransitions();
+ media.reduce = true;
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me');
+ await liveUpdate(CLAIMED);
+ expect(document.startViewTransition).not.toHaveBeenCalled();
+ expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument();
+ });
+
+ test('no glide on first load, even for a claim already made elsewhere', async () => {
+ stubViewTransitions();
+ taskRows = CLAIMED;
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'In Progress' })).findByText('Claim me');
+ expect(document.startViewTransition).not.toHaveBeenCalled();
+ });
+
+ test('no glide for a task first seen already claimed: it was never on this board to move', async () => {
+ stubViewTransitions();
+ taskRows = [PENDING[1]];
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Stay put');
+ await liveUpdate(CLAIMED);
+ expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument();
+ expect(document.startViewTransition).not.toHaveBeenCalled();
+ });
+
+ test('no glide on a refetch the user started (creating a task)', async () => {
+ stubViewTransitions();
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me');
+ taskRows = CLAIMED;
+ fireEvent.click(screen.getAllByRole('button', { name: 'New task' })[0]);
+ fireEvent.change(screen.getByPlaceholderText(/what needs/i), { target: { value: 'Another' } });
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create task' })); });
+ await within(column('In Progress')).findByText('Claim me');
+ expect(document.startViewTransition).not.toHaveBeenCalled();
+ });
+
+ test('without the API a live change still lands, at once', async () => {
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me');
+ await liveUpdate(CLAIMED);
+ expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument();
+ expect(document.documentElement.classList.contains('v2-board-glide')).toBe(false);
+ });
+});
+
+describe('board motion at 390: a card leaves the open tab', () => {
+ test('it fades toward its new tab, that tab ticks, and the open tab stays put', async () => {
+ stubViewTransitions();
+ media.phone = true;
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me');
+
+ await liveUpdate(CLAIMED);
+
+ expect(document.startViewTransition).not.toHaveBeenCalled();
+ const ghost = within(column('Pending')).getByText('Claim me').closest('article');
+ expect(ghost).toHaveClass('is-leaving', 'is-leaving--next');
+ expect(ghost).toHaveAttribute('inert');
+ expect(screen.getByRole('tab', { name: /Pending/ })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByRole('tab', { name: /In Progress/ }).querySelector('.v2-board__tab-count')).toHaveClass('is-ticking');
+ expect(screen.getByRole('tab', { name: /Pending/ }).querySelector('.v2-board__tab-count')).not.toHaveClass('is-ticking');
+
+ fireEvent.animationEnd(ghost);
+ expect(within(column('Pending')).queryByText('Claim me')).toBeNull();
+ });
+
+ test('a card going back a tab fades the other way, and leaves even with no animationend', async () => {
+ media.phone = true;
+ taskRows = CLAIMED;
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Stay put');
+ fireEvent.click(screen.getByRole('tab', { name: /In Progress/ }));
+ await liveUpdate(PENDING);
+ const ghost = within(column('In Progress')).getByText('Claim me').closest('article');
+ expect(ghost).toHaveClass('is-leaving--prev');
+ await waitFor(() => expect(within(column('In Progress')).queryByText('Claim me')).toBeNull(), { timeout: EXIT_SAFETY_MS + 500 });
+ });
+
+ test('no leaving card under reduced motion', async () => {
+ media.phone = true;
+ media.reduce = true;
+ renderBoard();
+ await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me');
+ await liveUpdate(CLAIMED);
+ expect(within(column('Pending')).queryByText('Claim me')).toBeNull();
+ });
+});
+
+describe('board motion: the task drawer', () => {
+ // jsdom has no animations; report the exit animation the stylesheet would apply.
+ const withExitAnimation = () => {
+ window.getComputedStyle = (node, pseudo) => new Proxy(realGetComputedStyle(node, pseudo), {
+ get(target, key) {
+ if (key === 'animationName') return node.hasAttribute?.('data-leaving') ? 'v2-board-drawer-out' : 'v2-board-drawer-in';
+ const value = target[key];
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ });
+ };
+ const openDrawer = async () => {
+ renderBoard();
+ fireEvent.click(await screen.findByText('Claim me'));
+ return screen.getByRole('dialog', { name: 'Claim me' });
+ };
+
+ test('closing keeps it mounted and inert until its slide out ends', async () => {
+ withExitAnimation();
+ const drawer = await openDrawer();
+ expect(drawer).not.toHaveAttribute('data-leaving');
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+
+ const leaving = document.querySelector('.v2-board__drawer');
+ expect(leaving).toHaveAttribute('data-leaving');
+ expect(leaving.parentElement).toHaveAttribute('inert');
+ expect(leaving.parentElement).toHaveAttribute('data-leaving');
+ fireEvent.animationEnd(leaving);
+ expect(document.querySelector('.v2-board__drawer')).toBeNull();
+ });
+
+ test('a drawer whose exit never reports back still unmounts', async () => {
+ withExitAnimation();
+ const drawer = await openDrawer();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(document.querySelector('.v2-board__drawer')).not.toBeNull();
+ await waitFor(() => expect(document.querySelector('.v2-board__drawer')).toBeNull(), { timeout: EXIT_SAFETY_MS + 500 });
+ });
+
+ test('under reduced motion it closes at once', async () => {
+ withExitAnimation();
+ media.reduce = true;
+ const drawer = await openDrawer();
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
+ expect(document.querySelector('.v2-board__drawer')).toBeNull();
+ });
+});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 88844d91ac..8139040d37 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -2515,11 +2515,13 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(v2).not.toMatch(/\.v2-msg__prose--closed::after/);
});
- it('the long-prose fold keeps both its max-height and overflow clip', () => {
+ it('the long-prose fold keeps both its 132px height and overflow clip', () => {
+ // The fold sizes between 132px and auto (motion pass), so the six-line cap is a
+ // block-size rather than a max-height; it must still be 132px and still clip.
const block = v2.match(/\.v2-msg__prose--closed \{[^}]*\}/);
expect(block).not.toBeNull();
- expect(block![0]).toMatch(/max-height:\s*132px/);
- expect(block![0]).toMatch(/overflow:\s*hidden/);
+ expect(block![0]).toMatch(/block-size:\s*132px/);
+ expect(block![0]).toMatch(/overflow:\s*clip/);
});
it('the BEND-1 feature type step exists in v2.css and tokens.css together', () => {
diff --git a/frontend/src/v2/__tests__/v2-motion.test.ts b/frontend/src/v2/__tests__/v2-motion.test.ts
new file mode 100644
index 0000000000..d6c7f6ccdb
--- /dev/null
+++ b/frontend/src/v2/__tests__/v2-motion.test.ts
@@ -0,0 +1,168 @@
+import fs from 'fs';
+import path from 'path';
+
+// D220: the motion contract from the d215 motion spec. Tokens, one global
+// reduced-motion switch, folds by size, and no blanket transitions.
+const v2Dir = path.join(__dirname, '..');
+const v2css = fs.readFileSync(path.join(v2Dir, 'v2.css'), 'utf8');
+
+const cssFiles = (dir: string): string[] => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) return entry.name === '__tests__' ? [] : cssFiles(full);
+ return entry.name.endsWith('.css') ? [full] : [];
+});
+
+describe('v2 motion (D220)', () => {
+ test('the tokens are one curve and three durations on :root', () => {
+ const root = /:root \{\n --v2-dur-fast: 120ms;\n --v2-dur: 200ms;\n --v2-dur-slow: 320ms;\n --v2-ease: cubic-bezier\(0\.2, 0, 0, 1\);\n\}/;
+ expect(v2css).toMatch(root);
+ });
+
+ test('reduced motion is one global switch that also reaches portals and view transitions', () => {
+ const block = v2css.slice(v2css.lastIndexOf('@media (prefers-reduced-motion: reduce) {'));
+ // Unscoped: a .v2-root prefix would miss portals such as the avatar crop dialog.
+ expect(block).toMatch(/^@media \(prefers-reduced-motion: reduce\) \{\n \*,\n \*::before,\n \*::after \{/);
+ expect(block).toContain('animation-duration: 0.01ms !important;');
+ expect(block).toContain('animation-iteration-count: 1 !important;');
+ expect(block).toContain('transition-duration: 0.01ms !important;');
+ expect(block).toContain('::view-transition-old(*),');
+ expect(block).toContain('animation: none !important;');
+ // The fold body is a pseudo-element the wildcards miss; it needs its own rule.
+ expect(block).toContain(' ::details-content {\n transition-duration: 0.01ms !important;\n }');
+ });
+
+ test('details folds open by size over --v2-dur and close over --v2-dur-fast', () => {
+ expect(v2css).toContain('.v2-root details {\n interpolate-size: allow-keywords;\n}');
+ const closed = v2css.slice(v2css.indexOf('.v2-root details::details-content {'));
+ expect(closed).toContain('block-size: 0;');
+ expect(closed).toContain('block-size var(--v2-dur-fast) var(--v2-ease)');
+ expect(closed).toContain('content-visibility var(--v2-dur-fast) var(--v2-ease) allow-discrete');
+ const open = v2css.slice(v2css.indexOf('.v2-root details[open]::details-content {'));
+ expect(open).toContain('block-size: auto;');
+ expect(open).toContain('transition-duration: var(--v2-dur);');
+ });
+
+ test('no v2 stylesheet uses a blanket transition', () => {
+ const offenders = cssFiles(v2Dir).filter((file) => /transition(-property)?:\s*all\b/.test(fs.readFileSync(file, 'utf8')));
+ expect(offenders.map((file) => path.relative(v2Dir, file))).toEqual([]);
+ });
+});
+
+describe('v2 motion: chat', () => {
+ const chatMessageCss = fs.readFileSync(path.join(v2Dir, 'chat', 'chat-message.css'), 'utf8');
+ const chatPaneCss = fs.readFileSync(path.join(v2Dir, 'chat', 'chat-pane.css'), 'utf8');
+ const rule = (css: string, selector: string): string => {
+ const start = css.indexOf(`${selector} {`);
+ expect(start).toBeGreaterThan(-1);
+ return css.slice(start, css.indexOf('}', start) + 1);
+ };
+ const keyframes = (css: string, name: string): string => {
+ const start = css.indexOf(`@keyframes ${name} {`);
+ expect(start).toBeGreaterThan(-1);
+ return css.slice(start, css.indexOf('}\n}', start) + 3);
+ };
+
+ test('a live message enters over --v2-dur on the one curve, by opacity and 4px of transform only', () => {
+ expect(rule(chatMessageCss, '.v2-msg--enter')).toContain('animation: v2-msg-enter var(--v2-dur) var(--v2-ease) backwards;');
+ const frames = keyframes(chatMessageCss, 'v2-msg-enter');
+ expect(frames).toContain('opacity: 0;');
+ expect(frames).toContain('transform: translateY(4px);');
+ expect(frames.replace(/opacity: 0;|transform: translateY\(4px\);/g, '')).not.toMatch(/[a-z-]+:\s*[^;]+;/);
+ });
+
+ test('content replacing the loader fades in over --v2-dur, opacity only', () => {
+ expect(rule(chatPaneCss, '.v2-chat__messages--reveal')).toContain('animation: v2-chat-reveal var(--v2-dur) var(--v2-ease) backwards;');
+ expect(keyframes(chatPaneCss, 'v2-chat-reveal')).toMatch(/from \{ opacity: 0; \}/);
+ });
+
+ test('Show more opens by size over --v2-dur and closes over --v2-dur-fast, with no max-height', () => {
+ const fold = rule(chatMessageCss, '.v2-msg__prose--fold');
+ expect(fold).toContain('interpolate-size: allow-keywords;');
+ expect(fold).toContain('block-size: auto;');
+ expect(fold).toContain('transition: block-size var(--v2-dur) var(--v2-ease);');
+ const closed = rule(chatMessageCss, '.v2-msg__prose--closed');
+ expect(closed).toContain('block-size: 132px;');
+ expect(closed).toContain('transition-duration: var(--v2-dur-fast);');
+
+ const code = rule(chatMessageCss, '.v2-msg__collapse pre');
+ expect(code).toContain('block-size: auto;');
+ expect(code).toContain('transition: block-size var(--v2-dur) var(--v2-ease);');
+ expect(rule(chatMessageCss, '.v2-msg__collapse--closed pre')).toContain('transition-duration: var(--v2-dur-fast);');
+
+ const folds = [fold, closed, code, rule(chatMessageCss, '.v2-msg__collapse--closed pre')].join('\n');
+ expect(folds).not.toMatch(/max-height/);
+ });
+
+ test('the Show more chevron turns on the same tokens: open over --v2-dur, back over --v2-dur-fast', () => {
+ expect(rule(chatMessageCss, '.v2-msg__showmore svg')).toContain('transition: transform var(--v2-dur-fast) var(--v2-ease);');
+ expect(rule(chatMessageCss, ".v2-msg__showmore[aria-expanded='true'] svg")).toContain('transition-duration: var(--v2-dur);');
+ });
+});
+
+// The board and drawer moments: the claim glide, the 390 leave, the lease ring
+// that waits for the landing, and drawers that slide in from the right.
+describe('v2 motion: board and drawers', () => {
+ const boardCss = fs.readFileSync(path.join(v2Dir, 'components', 'V2PodBoard.css'), 'utf8');
+ const cxCss = fs.readFileSync(path.join(v2Dir, 'connectors', 'connectors.css'), 'utf8');
+ const keyframes = (css: string, name: string): string => {
+ const start = css.indexOf(`@keyframes ${name} {`);
+ if (start < 0) return '';
+ let depth = 0;
+ for (let i = css.indexOf('{', start); i < css.length; i += 1) {
+ if (css[i] === '{') depth += 1;
+ if (css[i] === '}') { depth -= 1; if (!depth) return css.slice(start, i + 1); }
+ }
+ return '';
+ };
+
+ test('a live claim names the cards of the columns involved and glides on the slow token', () => {
+ expect(boardCss).toContain(':root.v2-board-glide .v2-feature--board .v2-board__col[data-glide] .v2-board__card { view-transition-name: var(--v2-task-vt); }');
+ expect(boardCss).toContain(':root.v2-board-glide::view-transition-group(*) { animation-duration: var(--v2-dur-slow); animation-timing-function: var(--v2-ease); }');
+ });
+
+ test('the lease ring waits for the landing and stops under reduced motion', () => {
+ expect(boardCss).toMatch(/:root\.v2-board-glide \.v2-feature--board \.v2-board__owner-dot--live::after,\n:root\.v2-board-glide \.v2-feature--board \.v2-board__col-dot--claimed\.is-live::after \{ animation: none; \}/);
+ expect(boardCss).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\n \.v2-feature--board \.v2-board__col-dot--claimed\.is-live::after,\n \.v2-feature--board \.v2-board__owner-dot--live::after \{ animation: none; \}/);
+ // A column dot loops only while a lease in it is live.
+ expect(boardCss).not.toMatch(/\.v2-board__col-dot--claimed::after/);
+ });
+
+ test('at 390 a card leaves its tab by 8px toward the new one over --v2-dur, and the count ticks', () => {
+ expect(boardCss).toContain('.v2-feature--board .v2-board__card.is-leaving { pointer-events: none; animation: v2-board-leave-next var(--v2-dur) var(--v2-ease) forwards; }');
+ expect(keyframes(boardCss, 'v2-board-leave-next')).toContain('to { opacity: 0; transform: translateX(8px); }');
+ expect(keyframes(boardCss, 'v2-board-leave-prev')).toContain('to { opacity: 0; transform: translateX(-8px); }');
+ expect(boardCss).toContain('.v2-board__tab-count.is-ticking { display: inline-block; animation: v2-board-tick var(--v2-dur-fast) var(--v2-ease); }');
+ });
+
+ test('the board drawer slides from the right, slow in and fast out, and its phone scrim fades', () => {
+ expect(boardCss).toContain('.v2-feature--board .v2-modal.v2-board__drawer { animation: v2-board-drawer-in var(--v2-dur-slow) var(--v2-ease); }');
+ expect(boardCss).toContain('.v2-feature--board .v2-modal.v2-board__drawer[data-leaving] { animation: v2-board-drawer-out var(--v2-dur-fast) var(--v2-ease) forwards; }');
+ expect(keyframes(boardCss, 'v2-board-drawer-in')).toContain('from { transform: translateX(100%); }');
+ expect(keyframes(boardCss, 'v2-board-drawer-out')).toContain('to { transform: translateX(100%); }');
+ expect(boardCss).toContain('.v2-board__overlay--drawer::before { content: \'\'; position: absolute; inset: 0; background: var(--v2-scrim); animation: v2-board-scrim-in var(--v2-dur-slow) var(--v2-ease); }');
+ expect(boardCss).toContain('.v2-board__overlay--drawer[data-leaving]::before { animation: v2-board-scrim-out var(--v2-dur-fast) var(--v2-ease) forwards; }');
+ });
+
+ test('the connector drawer slides from the right, slow in and fast out, without scrolling sideways', () => {
+ expect(cxCss).toContain('.v2-cx-drawer { animation: v2-cx-drawer-in var(--v2-dur-slow) var(--v2-ease); }');
+ expect(cxCss).toContain('.v2-cx-drawer.is-leaving { pointer-events: none; animation: v2-cx-drawer-out var(--v2-dur-fast) var(--v2-ease) forwards; }');
+ expect(keyframes(cxCss, 'v2-cx-drawer-in')).toContain('from { transform: translateX(100%); }');
+ expect(keyframes(cxCss, 'v2-cx-drawer-out')).toContain('to { transform: translateX(100%); }');
+ expect(cxCss).toContain('.v2-cx-body { overflow-x: hidden; }');
+ });
+
+ test('these moments move transform and opacity only', () => {
+ const names = ['v2-board-leave-next', 'v2-board-leave-prev', 'v2-board-tick', 'v2-board-drawer-in', 'v2-board-drawer-out', 'v2-board-scrim-in', 'v2-board-scrim-out'];
+ const blocks = [...names.map((name) => keyframes(boardCss, name)), keyframes(cxCss, 'v2-cx-drawer-in'), keyframes(cxCss, 'v2-cx-drawer-out')];
+ blocks.forEach((block) => {
+ expect(block).not.toBe('');
+ const props = Array.from(block.matchAll(/([a-z-]+)\s*:/g)).map((match) => match[1]);
+ expect(props.filter((prop) => prop !== 'transform' && prop !== 'opacity')).toEqual([]);
+ });
+ });
+
+ test('board and connector transitions use the tokens, never a hard-coded time', () => {
+ const transitions = `${boardCss}\n${cxCss}`.match(/transition:[^;}]*/g) || [];
+ transitions.forEach((decl) => expect(decl).not.toMatch(/\d(ms|s)\b/));
+ });
+});
diff --git a/frontend/src/v2/chat/chat-message.css b/frontend/src/v2/chat/chat-message.css
index 74647d7ba9..b005ca6360 100644
--- a/frontend/src/v2/chat/chat-message.css
+++ b/frontend/src/v2/chat/chat-message.css
@@ -880,17 +880,46 @@
font: 400 11px/16px var(--v2-font-mono);
}
-/* Long code collapses after six lines; expands in place. */
+/* Long code collapses after six lines; expands in place. It sizes to auto over
+ --v2-dur and back to six lines over --v2-dur-fast, with no measured height and
+ no max-height. Without interpolate-size it opens and closes at once, as before.
+ overflow-y stays hidden while it sizes, so no vertical scrollbar flashes. */
.v2-msg__collapse { position: relative; }
-.v2-msg__collapse--closed pre { max-height: calc(6 * 1.5 * 12.5px + 20px); overflow: hidden; }
+.v2-msg__collapse pre {
+ interpolate-size: allow-keywords;
+ block-size: auto;
+ overflow-y: hidden;
+ transition: block-size var(--v2-dur) var(--v2-ease);
+}
+
+.v2-msg__collapse--closed pre {
+ block-size: calc(6 * 1.5 * 12.5px + 20px);
+ overflow: hidden;
+ transition-duration: var(--v2-dur-fast);
+}
-/* Long agent prose folds after six lines (6 × 22px). The fade masks the prose itself, so a hovered row keeps its own surface. */
+/* Long agent prose folds after six lines (6 × 22px). The fade masks the prose itself, so a hovered row keeps its own surface.
+ A prose that folds sizes between 132px and auto: open over --v2-dur, closed over --v2-dur-fast, with
+ no measured height and no max-height. flow-root keeps the first child's margin inside the box in both
+ states, so the six-line preview matches the old overflow:hidden fold exactly. clip (not hidden) keeps
+ the box from becoming a scroller a focused link could scroll. Without interpolate-size it jumps, as before. */
.v2-msg__prose { position: relative; }
+.v2-msg__prose--fold {
+ display: flow-root;
+ interpolate-size: allow-keywords;
+ block-size: auto;
+ overflow: clip;
+ overflow-clip-margin: 6px;
+ transition: block-size var(--v2-dur) var(--v2-ease);
+}
+
.v2-msg__prose--closed {
- max-height: 132px;
- overflow: hidden;
+ block-size: 132px;
+ overflow: clip;
+ overflow-clip-margin: 0px;
+ transition-duration: var(--v2-dur-fast);
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 44px), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 44px), transparent);
}
@@ -915,9 +944,9 @@
.v2-root button.v2-msg__showmore:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--v2-accent-text); }
-.v2-msg__showmore svg { width: 14px; height: 14px; flex: none; transition: transform 160ms; }
+.v2-msg__showmore svg { width: 14px; height: 14px; flex: none; transition: transform var(--v2-dur-fast) var(--v2-ease); }
-.v2-msg__showmore[aria-expanded='true'] svg { transform: rotate(180deg); }
+.v2-msg__showmore[aria-expanded='true'] svg { transform: rotate(180deg); transition-duration: var(--v2-dur); }
.v2-root button.v2-msg__more {
display: inline-block;
@@ -1108,3 +1137,17 @@
.v2-root .v2-thread button.v2-msg__action::after { content: ''; position: absolute; inset: -8px; }
.v2-thread .v2-msg__gutter-time { display: none; }
}
+
+/* A message that arrives live enters once: opacity 0→1 and 4px up, over --v2-dur.
+ History, a pod just opened and older pages carry no enter class and appear at
+ once. backwards fill only, so no transform lingers on the row after it lands. */
+.v2-msg--enter {
+ animation: v2-msg-enter var(--v2-dur) var(--v2-ease) backwards;
+}
+
+@keyframes v2-msg-enter {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+}
diff --git a/frontend/src/v2/chat/chat-pane.css b/frontend/src/v2/chat/chat-pane.css
index 759f126e8b..26b0381cbc 100644
--- a/frontend/src/v2/chat/chat-pane.css
+++ b/frontend/src/v2/chat/chat-pane.css
@@ -52,6 +52,16 @@
overflow-anchor: none;
}
+/* Loading → content: what replaces the spinner fades in once over --v2-dur,
+ opacity only. Messages inside it appear together, with no per-row enter. */
+.v2-chat__messages--reveal {
+ animation: v2-chat-reveal var(--v2-dur) var(--v2-ease) backwards;
+}
+
+@keyframes v2-chat-reveal {
+ from { opacity: 0; }
+}
+
/* The transcript is the chat viewport's positioning context. History
recovery belongs to this viewport rather than the top of the scrollable
message list, otherwise a failure after a deep landing is stranded above
diff --git a/frontend/src/v2/components/V2CatchUpStrip.tsx b/frontend/src/v2/components/V2CatchUpStrip.tsx
index 525cb9992f..707414201c 100644
--- a/frontend/src/v2/components/V2CatchUpStrip.tsx
+++ b/frontend/src/v2/components/V2CatchUpStrip.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
+import V2Fold from './V2Fold';
// Sam's 2026-09-01 ruling: a constant TL;DR of what the agents are doing,
// living where the question is actually felt — pinned above the messages of
@@ -148,9 +149,9 @@ const V2CatchUpStrip: React.FC = ({ podId }) => {
×
- {expanded && summary?.content && (
-
{summary.content}
- )}
+
+
{summary?.content}
+
);
};
diff --git a/frontend/src/v2/components/V2Fold.tsx b/frontend/src/v2/components/V2Fold.tsx
new file mode 100644
index 0000000000..2cbf4ab8d5
--- /dev/null
+++ b/frontend/src/v2/components/V2Fold.tsx
@@ -0,0 +1,61 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+// A fold body that opens and closes by size: its grid row goes 0fr <-> 1fr, so no
+// pixel height is ever measured. A closing body stays mounted, inert, until the
+// close animation ends; a timer backs that up so it can never stay stuck open
+// (reduced motion, a hidden tab, or a browser that fires no animation events).
+// Only a change the reader makes animates. The first render lands as it is.
+const CLOSE_FALLBACK_MS = 200;
+
+type V2FoldProps = {
+ open: boolean;
+ id?: string;
+ className?: string;
+ children: React.ReactNode;
+};
+
+const V2Fold: React.FC = ({ open, id, className, children }) => {
+ const [rendered, setRendered] = useState(open);
+ const [phase, setPhase] = useState<'idle' | 'opening' | 'closing'>('idle');
+ const first = useRef(true);
+
+ useEffect(() => {
+ if (first.current) {
+ first.current = false;
+ return undefined;
+ }
+ if (open) {
+ setRendered(true);
+ setPhase('opening');
+ return undefined;
+ }
+ setPhase('closing');
+ const timer = window.setTimeout(() => {
+ setRendered(false);
+ setPhase('idle');
+ }, CLOSE_FALLBACK_MS);
+ return () => window.clearTimeout(timer);
+ }, [open]);
+
+ if (!rendered) return null;
+ const closing = phase === 'closing';
+ return (
+
{
+ if (event.target !== event.currentTarget) return;
+ if (closing) setRendered(false);
+ setPhase('idle');
+ }}
+ >
+ {/* The caller's class sits one level in, so its padding is clipped with the
+ content instead of holding the 0fr row open. */}
+
}
+ {content}
);
};
diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx
index fb6a044624..ac8c062236 100644
--- a/frontend/src/v2/components/V2MessageRow.tsx
+++ b/frontend/src/v2/components/V2MessageRow.tsx
@@ -374,7 +374,8 @@ export const CollapsiblePre: React.FC> = ({
};
// Agent prose past six lines (6 × 22px) folds in place; Show more expands it, Show less folds it.
-// Only the fold is measured, so a short reply renders exactly as before.
+// Only the fold is measured, so a short reply renders exactly as before. The measure only
+// decides WHETHER it folds; the open and close motion is CSS sizing to auto, never a JS height.
const PROSE_COLLAPSE_PX = 132;
export const CollapsibleProse: React.FC<{ collapsible: boolean; children: React.ReactNode }> = ({ collapsible, children }) => {
const { t } = useTranslation();
@@ -398,7 +399,7 @@ export const CollapsibleProse: React.FC<{ collapsible: boolean; children: React.
{/* Focus reaching a link below the fold opens the message, so the hidden link is never focused blind. */}
{
if (!folded) return;
setOpen(true);
@@ -460,8 +461,20 @@ export const landOnMessage = (id: string | number | null | undefined): boolean =
return true;
};
+// A live arrival enters once, when its row first mounts. The window keeps a
+// row that remounts later (a root gaining its first reply moves into a thread
+// block, a collapsed thread opens) from playing the motion a second time.
+export const LIVE_ENTER_WINDOW_MS = 1000;
+const arrivedJustNow = (message: V2Message): boolean => (
+ typeof message.liveArrivedAt === 'number' && Date.now() - message.liveArrivedAt < LIVE_ENTER_WINDOW_MS
+);
+
const V2MessageRow: React.FC = ({ message, sourceMessageId, decision, onDecisionRuled, isDecisionRuling = false, isLead, agentDisplayNames, agentTags, agentAuthorKeys, onAuthorClick, onOpenFile, onReply, onThread, onQuoteNavigate, grouped, insideThreadRoot }) => {
const { currentUser } = useAuth();
+ // Decided at mount and never again, so a re-render or a refetch that drops
+ // the flag cannot replay the enter.
+ const [enters] = useState(() => arrivedJustNow(message));
+ const enterClass = enters ? ' v2-msg--enter' : '';
const { t } = useTranslation();
const navigate = useNavigate();
const api = useV2Api();
@@ -526,7 +539,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d
&& message.payload.action === 'task-claimed') {
const actorLabel = overriddenDisplay || message.payload.actorName || author;
return (
-
+
= ({ message, sourceMessageId, d
if (decision) {
return (
-
+
= ({ message, sourceMessageId, d
// wrapper, so landOnMessage (a Threads row, a Needs-you link) can reach a
// card that roots a thread.
if (message.payload?.kind === 'approval-card') {
- return <>
>;
+ return <>
>;
}
if (message.payload?.kind === 'byo-handoff') {
- return <>
>;
+ return <>
>;
}
if (message.payload?.kind === 'ui') {
return {message.content}
}>{message.content}
}>;
}
if (message.payload?.kind === 'gig-card') {
- return <>
>;
+ return <>
>;
}
// §3.8 system event card. Detected by content shape (commonly-bot only
@@ -582,7 +595,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d
const dmEvent = parseAgentDmEvent(message.content);
if (dmEvent) {
return (
-