[codex] smooth route detail panel motion (#467)

* Keep route-detail motion smooth while restoring a more panel-like reveal feel

The route page detail panel originally reused the shared collapse container,
which looked lively but reintroduced layout-heavy motion inside the desktop
route grid. This change keeps the non-layout presence shell, then tunes the
panel animation itself so it reveals from the top with a softer, more
trace-panel-like feel without falling back to grid row interpolation.

Constraint: The desktop route detail panel sits inside a multi-column CSS grid and must avoid layout-driven collapse animation
Rejected: Reuse anim-collapse exactly as ProxyLogs does | reopening grid-template-rows animation brought back jank on route expand/collapse
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve transform/clip-path-based motion here unless grid placement is redesigned; do not reintroduce layout collapse without profiling
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: manual GitHub review in production browser after PR creation

* Keep route-detail motion PR lint-clean without changing behavior

CodeRabbit correctly flagged the new keyframes for using from/to selector
notation. This follow-up keeps the animation timing and properties the same
while switching to percentage selectors so the stylesheet aligns with the
configured keyframe selector rule.

Constraint: Keep the route-detail panel motion unchanged while satisfying the style rule mentioned in PR review
Rejected: Ignore the review comment | leaves a stylelint-style regression in the PR discussion unresolved
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When adding new keyframes in this stylesheet, prefer explicit percentage selectors to avoid repeating this lint issue
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run repo:drift-check
Not-tested: remote Git transport from this machine still hangs, so branch update was handled separately from local git push

* Respect reduced-motion and narrow layer promotion for route detail panel

The new route detail panel motion path solved grid-driven jank, but the follow-up
review correctly flagged two polish issues: the animation should honor reduced
motion, and will-change should only stay enabled during active transitions. This
keeps the smoother motion path while reducing persistent layer cost and giving
reduced-motion users immediate state changes.

Constraint: Must preserve the non-layout animation path that fixed dropped frames in the route grid
Rejected: Restore anim-collapse behavior | reintroduces grid/layout-driven jank
Confidence: high
Scope-risk: narrow
Directive: Keep desktop route detail motion on transform and clip-path rather than grid height animation unless the panel moves out of the card grid
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: Manual browser validation of prefers-reduced-motion on macOS

* Honor reduced-motion semantics in route detail panel lifecycle

The previous follow-up removed the always-on layer hint and added reduced-motion
CSS, but the enter state still reused the long-lived open class and the desktop
close path kept a fixed JS delay. This narrows will-change to a transient
entering state, bypasses close delays for reduced-motion users, and locks the
behavior with a regression test.

Constraint: Must preserve the non-layout detail-panel motion path that fixed grid jank
Rejected: Keep reduced-motion as CSS-only | JS close timer still delayed unmount and active-state cleanup
Rejected: Drop the close delay globally | would remove the intended close animation for standard-motion users
Confidence: high
Scope-risk: narrow
Directive: If the desktop detail panel motion changes again, keep motion-state CSS and JS timers aligned for prefers-reduced-motion users
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: Manual browser verification of prefers-reduced-motion on macOS
This commit is contained in:
Cita
2026-04-10 11:01:24 +08:00
committed by GitHub
parent c6a28c71c4
commit 8cf78a5354
3 changed files with 194 additions and 29 deletions
@@ -62,6 +62,7 @@ async function flushMicrotasks() {
describe('TokenRoutes desktop detail panel', () => {
const originalIntersectionObserver = globalThis.IntersectionObserver;
const originalMatchMedia = globalThis.matchMedia;
beforeEach(() => {
vi.clearAllMocks();
@@ -74,6 +75,20 @@ describe('TokenRoutes desktop detail panel', () => {
readonly rootMargin = '0px';
readonly thresholds = [];
} as unknown as typeof IntersectionObserver;
const defaultMatchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => false),
}));
globalThis.matchMedia = defaultMatchMedia as unknown as typeof matchMedia;
if (typeof window !== 'undefined') {
window.matchMedia = defaultMatchMedia as unknown as typeof window.matchMedia;
}
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary.mockResolvedValue([
@@ -136,6 +151,10 @@ describe('TokenRoutes desktop detail panel', () => {
afterEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = originalIntersectionObserver;
globalThis.matchMedia = originalMatchMedia;
if (typeof window !== 'undefined') {
window.matchMedia = originalMatchMedia as typeof window.matchMedia;
}
});
it('keeps summary cards stable and opens a separate desktop detail panel', async () => {
@@ -230,7 +249,7 @@ describe('TokenRoutes desktop detail panel', () => {
&& String(node.props.className || '').includes('route-detail-panel-presence')
));
expect(detailPanelPresence).toHaveLength(1);
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('anim-collapse');
expect(String(detailPanelPresence[0]!.props.className || '')).not.toContain('anim-collapse');
expect(String(detailPanelPresence[0]!.props.className || '')).not.toContain('is-open');
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('is-closing');
const detailPanelsWhileClosing = root.root.findAll((node) => (
@@ -288,4 +307,76 @@ describe('TokenRoutes desktop detail panel', () => {
vi.useRealTimers();
}
});
it('closes the desktop detail panel immediately when reduced motion is preferred', async () => {
vi.useFakeTimers();
const reducedMotionMatchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => false),
}));
globalThis.matchMedia = reducedMotionMatchMedia as unknown as typeof matchMedia;
if (typeof window !== 'undefined') {
window.matchMedia = reducedMotionMatchMedia as unknown as typeof window.matchMedia;
}
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const firstSummaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
await act(async () => {
await firstSummaryCard.props.onClick();
});
await flushMicrotasks();
const closeButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('收起详情')
));
await act(async () => {
await closeButton.props.onClick();
});
await flushMicrotasks();
await act(async () => {
vi.advanceTimersByTime(1);
});
await flushMicrotasks();
const detailPanelPresence = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-detail-panel-presence')
));
expect(detailPanelPresence).toHaveLength(0);
const summaryCardAfterClosing = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
expect(String(summaryCardAfterClosing.props.className || '')).not.toContain('is-active');
} finally {
root?.unmount();
vi.useRealTimers();
}
});
});