forked from boos/BoosAPI
[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-left-radius: 16px;
|
||||||
border-top-right-radius: 16px;
|
border-top-right-radius: 16px;
|
||||||
contain: layout paint;
|
contain: layout paint;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-detail-panel-presence {
|
.route-detail-panel-presence {
|
||||||
overflow: clip;
|
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 {
|
.route-detail-panel-presence.is-closing {
|
||||||
@@ -6779,18 +6777,71 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.route-detail-panel-presence.is-open .route-card-detail-panel {
|
.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 {
|
@keyframes route-detail-panel-in {
|
||||||
from {
|
0% {
|
||||||
opacity: 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;
|
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: [],
|
sourceRouteIds: [],
|
||||||
advancedOpen: false,
|
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 {
|
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
|
||||||
if (value === 'round_robin') return '已切换为轮询策略';
|
if (value === 'round_robin') return '已切换为轮询策略';
|
||||||
@@ -114,30 +120,44 @@ export function DesktopDetailPanelPresence({
|
|||||||
}) {
|
}) {
|
||||||
const [shouldRender, setShouldRender] = useState(open);
|
const [shouldRender, setShouldRender] = useState(open);
|
||||||
const [isOpen, setIsOpen] = useState(open);
|
const [isOpen, setIsOpen] = useState(open);
|
||||||
|
const [isEntering, setIsEntering] = useState(false);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const hasEverOpenedRef = useRef(open);
|
const hasEverOpenedRef = useRef(open);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const reduceMotion = prefersReducedMotion();
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
hasEverOpenedRef.current = true;
|
hasEverOpenedRef.current = true;
|
||||||
setShouldRender(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);
|
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) {
|
if (!hasEverOpenedRef.current) {
|
||||||
setShouldRender(false);
|
setShouldRender(false);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setIsEntering(false);
|
||||||
setIsClosing(false);
|
setIsClosing(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setIsEntering(false);
|
||||||
|
if (reduceMotion) {
|
||||||
|
setShouldRender(false);
|
||||||
|
setIsClosing(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
const timerId = globalThis.setTimeout(() => {
|
const timerId = globalThis.setTimeout(() => {
|
||||||
setShouldRender(false);
|
setShouldRender(false);
|
||||||
@@ -150,12 +170,10 @@ export function DesktopDetailPanelPresence({
|
|||||||
if (!shouldRender) return null;
|
if (!shouldRender) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<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' }}
|
style={{ gridColumn: '1 / -1' }}
|
||||||
>
|
>
|
||||||
<div className="anim-collapse-inner">
|
{children(isClosing)}
|
||||||
{children(isClosing)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1282,15 +1300,20 @@ export default function TokenRoutes() {
|
|||||||
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
||||||
if (isCurrentlyExpanded) {
|
if (isCurrentlyExpanded) {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
setClosingDesktopDetailRouteIds((prev) => (prev.includes(routeId) ? prev : [...prev, routeId]));
|
const reduceMotion = prefersReducedMotion();
|
||||||
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
|
if (reduceMotion) {
|
||||||
if (existingTimer) {
|
|
||||||
globalThis.clearTimeout(existingTimer);
|
|
||||||
}
|
|
||||||
desktopDetailCloseTimersRef.current[routeId] = globalThis.setTimeout(() => {
|
|
||||||
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
|
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
|
||||||
delete desktopDetailCloseTimersRef.current[routeId];
|
} else {
|
||||||
}, DESKTOP_DETAIL_COLLAPSE_MS);
|
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));
|
setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async function flushMicrotasks() {
|
|||||||
|
|
||||||
describe('TokenRoutes desktop detail panel', () => {
|
describe('TokenRoutes desktop detail panel', () => {
|
||||||
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
||||||
|
const originalMatchMedia = globalThis.matchMedia;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -74,6 +75,20 @@ describe('TokenRoutes desktop detail panel', () => {
|
|||||||
readonly rootMargin = '0px';
|
readonly rootMargin = '0px';
|
||||||
readonly thresholds = [];
|
readonly thresholds = [];
|
||||||
} as unknown as typeof IntersectionObserver;
|
} 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.mockReset();
|
||||||
getBrandMock.mockReturnValue(null);
|
getBrandMock.mockReturnValue(null);
|
||||||
apiMock.getRoutesSummary.mockResolvedValue([
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
@@ -136,6 +151,10 @@ describe('TokenRoutes desktop detail panel', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
globalThis.IntersectionObserver = originalIntersectionObserver;
|
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 () => {
|
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')
|
&& String(node.props.className || '').includes('route-detail-panel-presence')
|
||||||
));
|
));
|
||||||
expect(detailPanelPresence).toHaveLength(1);
|
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 || '')).not.toContain('is-open');
|
||||||
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('is-closing');
|
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('is-closing');
|
||||||
const detailPanelsWhileClosing = root.root.findAll((node) => (
|
const detailPanelsWhileClosing = root.root.findAll((node) => (
|
||||||
@@ -288,4 +307,76 @@ describe('TokenRoutes desktop detail panel', () => {
|
|||||||
vi.useRealTimers();
|
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