1
0
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:
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
+60 -9
View File
@@ -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);
}
}
+42 -19
View File
@@ -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();
}
});
});