[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:
+60
-9
@@ -6764,14 +6764,12 @@ textarea {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
contain: layout paint;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.route-detail-panel-presence {
|
||||
overflow: clip;
|
||||
contain: layout paint;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: grid-template-rows 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-closing {
|
||||
@@ -6779,18 +6777,71 @@ textarea {
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-open .route-card-detail-panel {
|
||||
animation: route-detail-panel-in 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-entering .route-card-detail-panel {
|
||||
will-change: transform, opacity, clip-path;
|
||||
animation: route-detail-panel-in 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-closing .route-card-detail-panel {
|
||||
will-change: transform, opacity, clip-path;
|
||||
animation: route-detail-panel-out 0.2s cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.route-detail-panel-presence.is-entering .route-card-detail-panel,
|
||||
.route-detail-panel-presence.is-closing .route-card-detail-panel {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-open .route-card-detail-panel {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-closing .route-card-detail-panel {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-6px) scale(0.994);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes route-detail-panel-in {
|
||||
from {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-8px) scale(0.992);
|
||||
}
|
||||
|
||||
to {
|
||||
62% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1.006);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes route-detail-panel-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-6px) scale(0.994);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,13 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
|
||||
sourceRouteIds: [],
|
||||
advancedOpen: false,
|
||||
};
|
||||
const DESKTOP_DETAIL_COLLAPSE_MS = 180;
|
||||
const DESKTOP_DETAIL_ENTER_MS = 260;
|
||||
const DESKTOP_DETAIL_COLLAPSE_MS = 200;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof globalThis.matchMedia === 'function'
|
||||
&& globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
|
||||
if (value === 'round_robin') return '已切换为轮询策略';
|
||||
@@ -114,30 +120,44 @@ export function DesktopDetailPanelPresence({
|
||||
}) {
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [isOpen, setIsOpen] = useState(open);
|
||||
const [isEntering, setIsEntering] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const hasEverOpenedRef = useRef(open);
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
|
||||
if (open) {
|
||||
hasEverOpenedRef.current = true;
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const rafId = window.requestAnimationFrame(() => setIsOpen(true));
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
setIsOpen(true);
|
||||
return undefined;
|
||||
setIsClosing(false);
|
||||
if (reduceMotion) {
|
||||
setIsEntering(false);
|
||||
return undefined;
|
||||
}
|
||||
setIsEntering(true);
|
||||
const enterTimerId = globalThis.setTimeout(() => {
|
||||
setIsEntering(false);
|
||||
}, DESKTOP_DETAIL_ENTER_MS);
|
||||
return () => globalThis.clearTimeout(enterTimerId);
|
||||
}
|
||||
|
||||
if (!hasEverOpenedRef.current) {
|
||||
setShouldRender(false);
|
||||
setIsOpen(false);
|
||||
setIsEntering(false);
|
||||
setIsClosing(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
setIsEntering(false);
|
||||
if (reduceMotion) {
|
||||
setShouldRender(false);
|
||||
setIsClosing(false);
|
||||
return undefined;
|
||||
}
|
||||
setIsClosing(true);
|
||||
const timerId = globalThis.setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
@@ -150,12 +170,10 @@ export function DesktopDetailPanelPresence({
|
||||
if (!shouldRender) return null;
|
||||
return (
|
||||
<div
|
||||
className={`route-detail-panel-presence anim-collapse route-panel-collapse ${isOpen ? 'is-open' : ''} ${isClosing ? 'is-closing' : ''}`.trim()}
|
||||
className={`route-detail-panel-presence ${isOpen ? 'is-open' : ''} ${isEntering ? 'is-entering' : ''} ${isClosing ? 'is-closing' : ''}`.trim()}
|
||||
style={{ gridColumn: '1 / -1' }}
|
||||
>
|
||||
<div className="anim-collapse-inner">
|
||||
{children(isClosing)}
|
||||
</div>
|
||||
{children(isClosing)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1282,15 +1300,20 @@ export default function TokenRoutes() {
|
||||
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
||||
if (isCurrentlyExpanded) {
|
||||
if (!isMobile) {
|
||||
setClosingDesktopDetailRouteIds((prev) => (prev.includes(routeId) ? prev : [...prev, routeId]));
|
||||
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
|
||||
if (existingTimer) {
|
||||
globalThis.clearTimeout(existingTimer);
|
||||
}
|
||||
desktopDetailCloseTimersRef.current[routeId] = globalThis.setTimeout(() => {
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
if (reduceMotion) {
|
||||
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
|
||||
delete desktopDetailCloseTimersRef.current[routeId];
|
||||
}, DESKTOP_DETAIL_COLLAPSE_MS);
|
||||
} else {
|
||||
setClosingDesktopDetailRouteIds((prev) => (prev.includes(routeId) ? prev : [...prev, routeId]));
|
||||
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
|
||||
if (existingTimer) {
|
||||
globalThis.clearTimeout(existingTimer);
|
||||
}
|
||||
desktopDetailCloseTimersRef.current[routeId] = globalThis.setTimeout(() => {
|
||||
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
|
||||
delete desktopDetailCloseTimersRef.current[routeId];
|
||||
}, DESKTOP_DETAIL_COLLAPSE_MS);
|
||||
}
|
||||
}
|
||||
setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
|
||||
} else {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user