diff --git a/apps/web/src/features/trade/components/orderbook/RecentTradesTape.tsx b/apps/web/src/features/trade/components/orderbook/RecentTradesTape.tsx index 06e7f83f..545c870b 100644 --- a/apps/web/src/features/trade/components/orderbook/RecentTradesTape.tsx +++ b/apps/web/src/features/trade/components/orderbook/RecentTradesTape.tsx @@ -1,4 +1,5 @@ import { useRef } from "react" +import { useAnimatedRows } from "@workspace/ui/hooks/use-animated-rows" import { Skeleton } from "@workspace/ui/components/skeleton" import { Table, @@ -19,6 +20,7 @@ type Props = { export function RecentTradesTape({ symbol }: Props) { const { trades, status, isLoading } = useRecentTrades(symbol) const containerRef = useRef(null) + const attachTradeRow = useAnimatedRows(trades, (trade) => trade.id, true, isLoading) return (
@@ -77,7 +79,7 @@ export function RecentTradesTape({ symbol }: Props) { {trades.map((trade) => ( - {/* ds-allow: dense trade row font size */} + attachTradeRow(trade.id, node)} interactive={false} className="font-mono text-[11px]">{/* ds-allow: dense trade row font size */} {formatUsd(trade.price, { decimals: 4 })} diff --git a/apps/web/src/features/trade/components/positions/OrdersList.tsx b/apps/web/src/features/trade/components/positions/OrdersList.tsx index 4be4da9b..787dc771 100644 --- a/apps/web/src/features/trade/components/positions/OrdersList.tsx +++ b/apps/web/src/features/trade/components/positions/OrdersList.tsx @@ -1,4 +1,5 @@ import { useState } from "react" +import { useAnimatedRows } from "@workspace/ui/hooks/use-animated-rows" import { Button } from "@workspace/ui/components/button" import { DataTable } from "@workspace/ui/components/data-table" import { StatusBadge } from "@workspace/ui/components/status-badge" @@ -178,6 +179,8 @@ export function OrdersList() { }, ] + const attachMobileOrder = useAnimatedRows(orders, (order) => order.clientOrderId, true, isLoading) + return ( <> {isDisabled && ( @@ -194,6 +197,7 @@ export function OrdersList() { o.clientOrderId} @@ -217,7 +221,7 @@ export function OrdersList() { const statusLabel = cancelling === o.key ? "Cancelling" : o.awaitingIndex ? "Pending index" : o.status === "frozen" ? "Frozen" : "Open" return ( -
+
attachMobileOrder(o.clientOrderId, node)} role="listitem"> row.id} diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx index 1fba2485..422cb3b4 100644 --- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx +++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx @@ -40,6 +40,7 @@ import { formatAddress } from "@/shared/lib/format" type Props = { open: boolean onClose: () => void + onConfirmed?: (hash: string) => void tradeState: ReturnType sizeUsd: number entryPrice: number @@ -50,6 +51,7 @@ type Props = { export function ConfirmationDialog({ open, onClose, + onConfirmed, tradeState, sizeUsd, entryPrice, @@ -304,10 +306,12 @@ export function ConfirmationDialog({ leverage: effectiveLeverage, } - await sendBatchOrderTxn(account, { + const confirmedHash = await sendBatchOrderTxn(account, { createOrders: [parentOrder, ...sidecarCreateOrders], }) + // sendBatchOrderTxn waits for ledger confirmation before resolving. + onConfirmed?.(confirmedHash) clearSidecarOrders() } onClose() diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx index 82da0cbb..8f40ec76 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react" +import { explorerTxUrl } from "@/app/config/network" import { Tabs, TabsContent, @@ -45,6 +46,7 @@ type TradePanelProps = { export function TradePanel({ trade }: TradePanelProps) { const { getMidPrice, isStale } = useTokenPrices() const [confirmOpen, setConfirmOpen] = useState(false) + const [confirmedHash, setConfirmedHash] = useState(null) const [priceValidationError, setPriceValidationError] = useState(null) const [previousMode, setPreviousMode] = useState(trade.tradeMode) const account = useWalletStore((state) => state.address) @@ -330,10 +332,27 @@ export function TradePanel({ trade }: TradePanelProps) { {tradeType} {!tradeFlags.isSwap && toTokenLabel} + {confirmedHash && ( +
+ + + Order confirmed on ledger + Your orders and positions update as indexing completes. + + + View tx + +
+ )} + {/* ── Confirmation modal ───────────────────────────────────── */} setConfirmOpen(false)} + onConfirmed={setConfirmedHash} tradeState={trade} sizeUsd={sizeUsd} entryPrice={entryPrice} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a4f05de4..365c5eac 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -6,6 +6,7 @@ import { createRootRoute, } from "@tanstack/react-router" import { ToastProvider } from "@workspace/ui/components/toast" +import { TooltipProvider } from "@workspace/ui/components/tooltip" import { DensityProvider } from "@workspace/ui/components/density-provider" import appCss from "@workspace/ui/globals.css?url" import { AppProviders } from "../app/providers" @@ -191,9 +192,11 @@ function RootDocument({ children }: { children: React.ReactNode }) { - - {children} - + + + {children} + + diff --git a/apps/web/src/ui/theme-provider.tsx b/apps/web/src/ui/theme-provider.tsx index 4ea5031e..23532a28 100644 --- a/apps/web/src/ui/theme-provider.tsx +++ b/apps/web/src/ui/theme-provider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState } from "react" +import { createContext, useContext, useEffect, useRef, useState } from "react" import type {ReactNode} from "react"; export type Theme = "dark" | "light" | "system" @@ -36,9 +36,66 @@ export function ThemeProvider({ children }: { children: ReactNode }) { return localStorage.getItem(HIGH_CONTRAST_KEY) === "true" }) + const activeTransition = useRef(null) + const setTheme = (next: Theme) => { - if (typeof window !== "undefined") localStorage.setItem(STORAGE_KEY, next) - setThemeState(next) + if (typeof window === "undefined") { + setThemeState(next) + return + } + const apply = () => { + localStorage.setItem(STORAGE_KEY, next) + const resolved = resolve(next) + const root = document.documentElement + root.classList.remove("dark", "light") + root.classList.add(resolved) + setThemeState(next) + } + + // Honour system accessibility preferences and avoid animating when the + // effective appearance does not change. + if ( + resolve(next) === resolve(theme) || + window.matchMedia("(prefers-reduced-motion: reduce)").matches || + document.documentElement.dataset.reducedMotion === "true" || + !document.startViewTransition + ) { + activeTransition.current?.skipTransition() + apply() + return + } + + // A second toggle may interrupt the first reveal without losing focus. + activeTransition.current?.skipTransition() + const trigger = document.activeElement?.getBoundingClientRect() + const x = trigger ? trigger.left + trigger.width / 2 : window.innerWidth / 2 + const y = trigger ? trigger.top + trigger.height / 2 : window.innerHeight / 2 + const radius = Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y), + ) + + const transition = document.startViewTransition(apply) + activeTransition.current = transition + void transition.ready.then(() => { + const css = getComputedStyle(document.documentElement) + const duration = Number.parseFloat(css.getPropertyValue("--duration-moderate")) || 200 + const easing = css.getPropertyValue("--ease-out").trim() || "ease-out" + try { + document.documentElement.animate( + [ + { clipPath: `circle(0px at ${x}px ${y}px)` }, + { clipPath: `circle(${radius}px at ${x}px ${y}px)` }, + ], + { duration, easing, fill: "both", pseudoElement: "::view-transition-new(root)" }, + ) + } catch { + // Unsupported pseudo-element animation: the class change still works. + } + }).catch(() => {}) + void transition.finished.finally(() => { + if (activeTransition.current === transition) activeTransition.current = null + }) } const setHighContrast = (enabled: boolean) => { diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx index 70798dcc..59c96079 100644 --- a/packages/ui/src/components/data-table.tsx +++ b/packages/ui/src/components/data-table.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { cn } from '@workspace/ui/lib/utils' import { Skeleton } from '@workspace/ui/components/skeleton' import { EmptyState } from '@workspace/ui/components/empty-state' +import { useAnimatedRows } from '@workspace/ui/hooks/use-animated-rows' import { Table, TableBody, @@ -31,6 +32,8 @@ interface DataTableProps { keyExtractor: (row: T) => string onRowClick?: (row: T) => void selectedRowKey?: string + /** FLIP insertion/removal/reorder animations for live trading data. */ + animateRows?: boolean className?: string } @@ -43,8 +46,11 @@ function DataTable({ keyExtractor, onRowClick, selectedRowKey, + animateRows = false, className, }: DataTableProps) { + const attachAnimatedRow = useAnimatedRows(data, keyExtractor, animateRows, isLoading) + if (isLoading) { return ( @@ -98,6 +104,7 @@ function DataTable({ return ( attachAnimatedRow(key, node) : undefined} interactive={!!onRowClick} aria-current={isSelected ? 'true' : undefined} tabIndex={onRowClick ? 0 : undefined} diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index aa4b32a4..0184925d 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -64,7 +64,7 @@ function TooltipContent({ ( + data: Array, + keyExtractor: (row: T) => string, + enabled = true, + isLoading = false, +) { + const currentNodes = React.useRef(new Map()) + const previousRows = React.useRef( + new Map }>(), + ) + const initialized = React.useRef(false) + + React.useLayoutEffect(() => { + if (!enabled || isLoading) { + previousRows.current.clear() + initialized.current = false + return + } + const old = previousRows.current + const next = new Map }>() + const css = getComputedStyle(document.documentElement) + const duration = Number.parseFloat(css.getPropertyValue('--duration-moderate')) || 200 + const easing = css.getPropertyValue('--ease-out').trim() || 'ease-out' + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches || + document.documentElement.dataset.reducedMotion === 'true' + + for (const row of data) { + const key = keyExtractor(row) + const node = currentNodes.current.get(key) + if (!node) continue + const rect = node.getBoundingClientRect() + next.set(key, { + rect, + node, + widths: Array.from(node.children, cell => cell.getBoundingClientRect().width), + }) + if (!initialized.current || reduced) continue + const previous = old.get(key) + const dy = previous ? previous.rect.top - rect.top : 10 + if (!previous || Math.abs(dy) > 1) { + // Web Animations interrupts from current compositor state instead of + // replaying a CSS keyframe when live data changes during an animation. + node.getAnimations().forEach(animation => animation.cancel()) + node.animate( + [ + { transform: `translateY(${dy}px)`, opacity: previous ? 1 : 0 }, + { transform: 'translateY(0)', opacity: 1 }, + ], + { duration, easing }, + ) + } + } + + // Exiting rows are inert visual ghosts: the DOM table is updated + // immediately for accessibility while the remaining rows FLIP into place. + if (initialized.current && !reduced) { + for (const [key, previous] of old) { + if (next.has(key) || previous.rect.bottom < 0 || previous.rect.top > innerHeight) continue + const ghost = document.createElement('table') + const body = document.createElement('tbody') + const clone = previous.node.cloneNode(true) as HTMLElement + Array.from(clone.children).forEach((cell, index) => { + ;(cell as HTMLElement).style.width = `${previous.widths[index]}px` + }) + if (clone.tagName === 'TR') { + body.append(clone) + ghost.append(body) + } else { + // Mobile order cards are block elements rather than table rows. + const wrapper = document.createElement('tbody') + const holder = document.createElement('tr') + const cell = document.createElement('td') + cell.append(clone) + holder.append(cell) + wrapper.append(holder) + ghost.append(wrapper) + } + ghost.setAttribute('aria-hidden', 'true') + ghost.inert = true + Object.assign(ghost.style, { + position: 'fixed', + left: `${previous.rect.left}px`, + top: `${previous.rect.top}px`, + width: `${previous.rect.width}px`, + tableLayout: 'fixed', + pointerEvents: 'none', + zIndex: '50', + }) + document.body.append(ghost) + const animation = ghost.animate( + [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(-8px)' }], + { duration, easing }, + ) + void animation.finished.finally(() => ghost.remove()).catch(() => ghost.remove()) + } + } + previousRows.current = next + initialized.current = true + }, [data, enabled, isLoading, keyExtractor]) + + return (key: string, node: HTMLElement | null) => { + if (node) currentNodes.current.set(key, node) + else currentNodes.current.delete(key) + } + +} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 11443fa8..cb90d21f 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -956,3 +956,39 @@ background-color: CanvasText !important; } } + + +/* Motion review #880–883: shared timing tokens, compositor-friendly surfaces. */ +@keyframes so4-confirm-check { + from { opacity: 0; transform: scale(0.65); } + to { opacity: 1; transform: scale(1); } +} +.so4-confirm-check { + animation: so4-confirm-check var(--duration-moderate) var(--ease-out) both; +} +.so4-confirm-moment { + animation: so4-confirm-check var(--duration-slow) var(--ease-out) both; +} +[data-slot="tooltip-content"] { + transition: opacity var(--duration-base) var(--ease-out), + transform var(--duration-base) var(--ease-out); +} +[data-slot="tooltip-content"][data-starting-style], +[data-slot="tooltip-content"][data-ending-style] { + opacity: 0; + transform: translateY(3px); +} +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} +::view-transition-old(root) { z-index: 1; } +::view-transition-new(root) { z-index: 2; } +@media (prefers-reduced-motion: reduce) { + .so4-confirm-check, .so4-confirm-moment { animation: none; } + [data-slot="tooltip-content"] { transition: none; } +} +:root[data-reduced-motion="true"] .so4-confirm-check, +:root[data-reduced-motion="true"] .so4-confirm-moment { animation: none; } +:root[data-reduced-motion="true"] [data-slot="tooltip-content"] { transition: none; }