diff --git a/apps/web/src/features/changelog/hooks/use-release-hash.ts b/apps/web/src/features/changelog/hooks/use-release-hash.ts index 89312cba..9a6b120a 100644 --- a/apps/web/src/features/changelog/hooks/use-release-hash.ts +++ b/apps/web/src/features/changelog/hooks/use-release-hash.ts @@ -7,9 +7,9 @@ function focusHashTarget() { const target = document.getElementById(window.location.hash.slice(1)) if (!target) return - const reducedMotion = window.matchMedia( - "(prefers-reduced-motion: reduce)" - ).matches + const reducedMotion = + document.documentElement.dataset.reducedMotion === "true" || + window.matchMedia("(prefers-reduced-motion: reduce)").matches target.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx index 216520b0..28fbbc9d 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx @@ -15,6 +15,8 @@ const mockChartMethods = { remove: vi.fn(), applyOptions: vi.fn(), timeScale: vi.fn(() => mockTimeScale), + subscribeCrosshairMove: vi.fn(), + unsubscribeCrosshairMove: vi.fn(), } const mockSeriesMethods = { @@ -141,6 +143,16 @@ describe("TVChartContainer", () => { expect(chart).toBeInTheDocument() }) + it("uses the magnetic crosshair and shows its OHLC legend", () => { + mockCandles = SAMPLE_CANDLES + render() + + expect(mockCreateChart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + crosshair: { mode: 1 }, + })) + expect(screen.getByTestId("ohlc-legend")).toBeInTheDocument() + }) + it("describes the chart as linked from the summary", () => { mockCandles = SAMPLE_CANDLES const { container } = render() diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx index b4713897..d5f3f416 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx @@ -1,6 +1,7 @@ -import { CandlestickSeries, LineStyle, createChart } from "lightweight-charts" +import { CandlestickSeries, CrosshairMode, LineStyle, createChart } from "lightweight-charts" import { useEffect, useMemo, useRef, useState } from "react" import { Skeleton } from "@workspace/ui/components/skeleton" +import { RollingNumber } from "@workspace/ui/components/rolling-number" import { VisuallyHidden } from "@workspace/ui/components/visually-hidden" import { LiveRegion, useAnnouncer } from "@workspace/ui/components/live-region" import { cn } from "@workspace/ui/lib/utils" @@ -28,9 +29,6 @@ import type { OhlcBar } from "../../lib/oracle" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { formatUsd } from "@/shared/lib/format" import { ENV } from "@/app/config/env" -import type { CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp } from "lightweight-charts" -import type { OhlcBar } from "../../lib/oracle" -import { useWalletStore } from "@/features/wallet/store/wallet-store" type ChartLine = { id: string @@ -40,6 +38,8 @@ type ChartLine = { lineStyle?: LineStyle } +type CrosshairEvent = Parameters[0]>[0] + type Props = { symbol: string period: string @@ -132,6 +132,8 @@ export function TVChartContainer({ symbol, period }: Props) { const account = useWalletStore((state) => state.address) const [showTable, setShowTable] = useState(false) + const [crosshairBar, setCrosshairBar] = useState(null) + const crosshairTimeRef = useRef(null) const lastAnnounceRef = useRef(0) const announcer = useAnnouncer() @@ -146,6 +148,7 @@ export function TVChartContainer({ symbol, period }: Props) { const summary = useMemo(() => getChartSummary(candles, liveBar, symbol, period), [candles, liveBar, symbol, period]) const tableRows = useMemo(() => getRepresentativeRows(candles), [candles]) + const legendBar = crosshairBar ?? liveBar ?? candles.at(-1) ?? null const currentSymbolRef = useRef(symbol) const currentPeriodRef = useRef(period) @@ -158,6 +161,7 @@ export function TVChartContainer({ symbol, period }: Props) { const chart = createChart(containerRef.current, { ...buildChartOptions(palette), + crosshair: { mode: CrosshairMode.Magnet }, handleScroll: { mouseWheel: true, pressedMouseMove: true }, handleScale: { mouseWheel: true, pinch: true }, }) @@ -167,6 +171,15 @@ export function TVChartContainer({ symbol, period }: Props) { chartRef.current = chart seriesRef.current = series + // Snap to candles in the renderer and update React only on a new candle. + const onCrosshairMove = (event: CrosshairEvent) => { + const bar = event.seriesData.get(series) as CandlestickData | undefined + if (!bar || crosshairTimeRef.current === Number(bar.time)) return + crosshairTimeRef.current = Number(bar.time) + setCrosshairBar({ time: Number(bar.time), open: bar.open, high: bar.high, low: bar.low, close: bar.close }) + } + chart.subscribeCrosshairMove(onCrosshairMove) + // Responsive resize — guard against zero-size to avoid chart overflow or // dimension errors. Preserve the chart instance across ordinary layout changes. const resizeObserver = new ResizeObserver(() => { @@ -199,6 +212,7 @@ export function TVChartContainer({ symbol, period }: Props) { return () => { resizeObserver.disconnect() themeObserver.disconnect() + chart.unsubscribeCrosshairMove(onCrosshairMove) chart.remove() chartRef.current = null seriesRef.current = null @@ -215,6 +229,8 @@ export function TVChartContainer({ symbol, period }: Props) { const symbolChanged = currentSymbolRef.current !== symbol currentSymbolRef.current = symbol currentPeriodRef.current = period + crosshairTimeRef.current = null + setCrosshairBar(null) if (!seriesRef.current) return seriesRef.current.setData([]) @@ -365,9 +381,19 @@ export function TVChartContainer({ symbol, period }: Props) { {sourceType === "oracle_reference" ? `Reference Price (${venueName})` : `Execution Fills (${venueName})`} - - {network} - +
+ {legendBar && ( +
+ {(["open", "high", "low", "close"] as const).map((key) => ( +
+
{key[0].toUpperCase()}
+
+
+ ))} +
+ )} + {network} +
{/* ═══ Chart area ═══════════════════════════════════════════════════════ */} @@ -455,7 +481,7 @@ export function TVChartContainer({ symbol, period }: Props) { {/* Lightweight Charts canvas — hidden from AT; the VisuallyHidden summary replaces it */}
onChange(Array.isArray(v) ? v[0] : v)} + onValueChange={(v) => { + const next = Array.isArray(v) ? v[0] : v + if (next !== value) haptic("detent") + onChange(next) + }} className="w-full" />
diff --git a/apps/web/src/lib/motion-preference.ts b/apps/web/src/lib/motion-preference.ts new file mode 100644 index 00000000..be429ec8 --- /dev/null +++ b/apps/web/src/lib/motion-preference.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react" + +const STORAGE_KEY = "so4-reduced-motion" +const CHANGE_EVENT = "so4:reduced-motion-change" + +function readPreference(): boolean { + return typeof window !== "undefined" && localStorage.getItem(STORAGE_KEY) === "true" +} + +function applyPreference(enabled: boolean): void { + document.documentElement.dataset.reducedMotion = String(enabled) +} + +/** Persist and broadcast the app-level motion preference. */ +export function setMotionReduction(enabled: boolean): void { + localStorage.setItem(STORAGE_KEY, String(enabled)) + applyPreference(enabled) + window.dispatchEvent(new Event(CHANGE_EVENT)) +} + +/** Combines the system preference with the explicit app preference. */ +export function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false) + + useEffect(() => { + const media = window.matchMedia("(prefers-reduced-motion: reduce)") + const sync = () => { + const enabled = media.matches || readPreference() + applyPreference(readPreference()) + setReduced(enabled) + } + sync() + media.addEventListener("change", sync) + window.addEventListener(CHANGE_EVENT, sync) + return () => { + media.removeEventListener("change", sync) + window.removeEventListener(CHANGE_EVENT, sync) + } + }, []) + + return reduced +} + +export function useMotionReductionSetting(): readonly [boolean, (enabled: boolean) => void] { + const [enabled, setEnabled] = useState(readPreference) + + useEffect(() => { + const sync = () => setEnabled(readPreference()) + sync() + window.addEventListener(CHANGE_EVENT, sync) + return () => window.removeEventListener(CHANGE_EVENT, sync) + }, []) + + return [enabled, setMotionReduction] as const +} diff --git a/apps/web/src/lib/use-reduced-motion.test.ts b/apps/web/src/lib/use-reduced-motion.test.ts index a9b5635d..f9b3e3cb 100644 --- a/apps/web/src/lib/use-reduced-motion.test.ts +++ b/apps/web/src/lib/use-reduced-motion.test.ts @@ -1,7 +1,7 @@ import { act, renderHook } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { useReducedMotion } from "./use-reduced-motion" +import { setMotionReduction, useReducedMotion } from "./motion-preference" describe("useReducedMotion", () => { let mediaQueryListMock: { @@ -27,6 +27,8 @@ describe("useReducedMotion", () => { afterEach(() => { vi.restoreAllMocks() + localStorage.removeItem("so4-reduced-motion") + delete document.documentElement.dataset.reducedMotion }) it("returns false initially (SSR-safe hydration)", () => { @@ -93,6 +95,15 @@ describe("useReducedMotion", () => { ) }) + it("honours the in-app setting and exposes it to CSS", () => { + const { result } = renderHook(() => useReducedMotion()) + + act(() => setMotionReduction(true)) + + expect(result.current).toBe(true) + expect(document.documentElement.dataset.reducedMotion).toBe("true") + }) + it("does not leak timers or listeners on rapid mount/unmount", () => { const { rerender, unmount } = renderHook(() => useReducedMotion()) diff --git a/apps/web/src/lib/use-reduced-motion.ts b/apps/web/src/lib/use-reduced-motion.ts index cfa3e257..bc7b8fa0 100644 --- a/apps/web/src/lib/use-reduced-motion.ts +++ b/apps/web/src/lib/use-reduced-motion.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +export { useReducedMotion } from "./motion-preference" /** * Hook that detects the system's prefers-reduced-motion preference and updates @@ -19,28 +19,3 @@ import { useEffect, useState } from "react" * } * }, [reducedMotion]) */ -export function useReducedMotion(): boolean { - // Default to false on server render to match initial hydration - const [reducedMotion, setReducedMotion] = useState(false) - const [mounted, setMounted] = useState(false) - - useEffect(() => { - // Query the current preference - const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") - setReducedMotion(mediaQuery.matches) - setMounted(true) - - // Listen for changes to the preference - const handleChange = (event: MediaQueryListEvent) => { - setReducedMotion(event.matches) - } - - mediaQuery.addEventListener("change", handleChange) - return () => { - mediaQuery.removeEventListener("change", handleChange) - } - }, []) - - // Return false until mounted to ensure hydration matches server render - return mounted ? reducedMotion : false -} diff --git a/apps/web/src/shared/hooks/useTxSubmit.tsx b/apps/web/src/shared/hooks/useTxSubmit.tsx index 7dff23a0..7c40b1be 100644 --- a/apps/web/src/shared/hooks/useTxSubmit.tsx +++ b/apps/web/src/shared/hooks/useTxSubmit.tsx @@ -3,6 +3,7 @@ import { toast } from "@workspace/ui/components/toast" import { sendAndPoll } from "@/lib/tx-builder" import { explorerTxUrl } from "@/app/config/network" import { useTxFeedback } from "./useTxFeedback" +import { haptic } from "@/shared/lib/haptics" export type SubmitTxOptions = { loadingMessage: string @@ -46,6 +47,7 @@ export async function submitTx(
), }) + haptic("success") // Execute onSuccess as a side effect after transaction confirmation, // but don't let its errors overwrite transaction success state @@ -64,6 +66,7 @@ export async function submitTx( } catch (error) { const message = options.onError?.(error) ?? "Transaction failed" toast.error(message, { id: toastId }) + haptic("error") throw error } } @@ -166,6 +169,7 @@ export function useTxSubmit() { message: options.successMessage, description: options.successDescription?.(hash), }) + haptic("success") setState({ isLoading: false, txHash: hash, error: null }) submitInProgressRef.current = false @@ -173,6 +177,7 @@ export function useTxSubmit() { } catch (error) { const message = options.onError?.(error) ?? "Transaction failed" feedback.handleEvent({ type: "error", message }) + haptic("error") setState({ isLoading: false, txHash: null, diff --git a/apps/web/src/shared/lib/haptics.test.ts b/apps/web/src/shared/lib/haptics.test.ts new file mode 100644 index 00000000..402d303d --- /dev/null +++ b/apps/web/src/shared/lib/haptics.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { haptic } from "./haptics" + +describe("haptic", () => { + afterEach(() => { + vi.restoreAllMocks() + delete document.documentElement.dataset.reducedMotion + }) + + it("uses a short success vibration when supported", () => { + const vibrate = vi.fn() + Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate }) + + haptic("success") + + expect(vibrate).toHaveBeenCalledWith(12) + }) + + it("does nothing when reduced motion is selected", () => { + const vibrate = vi.fn() + Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate }) + document.documentElement.dataset.reducedMotion = "true" + + haptic("error") + + expect(vibrate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/shared/lib/haptics.ts b/apps/web/src/shared/lib/haptics.ts new file mode 100644 index 00000000..c2cbf36f --- /dev/null +++ b/apps/web/src/shared/lib/haptics.ts @@ -0,0 +1,18 @@ +type HapticKind = "success" | "error" | "detent" + +const patterns: Record> = { + success: 12, + error: [18, 35, 18], + detent: 6, +} + +/** Android vibration with an intentionally silent no-op on unsupported devices. */ +export function haptic(kind: HapticKind): void { + if ( + typeof navigator === "undefined" || + document.documentElement.dataset.reducedMotion === "true" || + !navigator.vibrate + ) return + + navigator.vibrate(patterns[kind]) +} diff --git a/apps/web/src/ui/appearance-settings-panel.test.tsx b/apps/web/src/ui/appearance-settings-panel.test.tsx index 1d801b71..3eb6b9d5 100644 --- a/apps/web/src/ui/appearance-settings-panel.test.tsx +++ b/apps/web/src/ui/appearance-settings-panel.test.tsx @@ -74,6 +74,11 @@ describe("AppearanceSettingsPanel", () => { expect(screen.getByText("Sync with your device settings")).toBeInTheDocument() }) + it("provides an app-level reduce motion setting", () => { + renderWithTheme() + expect(screen.getByRole("switch", { name: /reduce motion/i })).toBeInTheDocument() + }) + it("renders preview cards by default", () => { const { container } = renderWithTheme() // Previews are aria-hidden divs diff --git a/apps/web/src/ui/appearance-settings-panel.tsx b/apps/web/src/ui/appearance-settings-panel.tsx index 33566703..93d3f8f4 100644 --- a/apps/web/src/ui/appearance-settings-panel.tsx +++ b/apps/web/src/ui/appearance-settings-panel.tsx @@ -3,7 +3,9 @@ import * as React from "react" import { RadioGroup, RadioGroupItem } from "@workspace/ui/components/radio-group" import { cn } from "@workspace/ui/lib/utils" +import { Switch } from "@workspace/ui/components/switch" import { useTheme } from "./theme-provider" +import { useMotionReductionSetting } from "@/lib/motion-preference" import type {Theme} from "./theme-provider"; interface ThemeOption { @@ -80,6 +82,7 @@ export function AppearanceSettingsPanel({ ...props }: AppearanceSettingsPanelProps) { const { theme, setTheme, resolvedTheme } = useTheme() + const [reduceMotion, setReduceMotion] = useMotionReductionSetting() const handleThemeChange = React.useCallback( (value: string) => { @@ -158,6 +161,13 @@ export function AppearanceSettingsPanel({

)} +
+
+ +

Use instant or opacity-only transitions throughout the app.

+
+ +
) } diff --git a/apps/web/src/ui/landing/animated-title.tsx b/apps/web/src/ui/landing/animated-title.tsx index 604b5b18..141d2e63 100644 --- a/apps/web/src/ui/landing/animated-title.tsx +++ b/apps/web/src/ui/landing/animated-title.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react" +import { useReducedMotion } from "@/lib/use-reduced-motion" // SO4 markets: the hero sentence is the fixed "Trade [word] from your // wallet" — every rotating word needs to read naturally in that slot. GMX's @@ -25,24 +26,13 @@ export function AnimatedTitle() { // since Playwright's animation freeze can't reliably override an inline // `style.animation` referencing a custom property). const [phase, setPhase] = useState<"idle" | "out" | "in">("idle") - const [reducedMotion, setReducedMotion] = useState( - () => - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ) + const reducedMotion = useReducedMotion() const [isPageHidden, setIsPageHidden] = useState(false) useEffect(() => { - const query = window.matchMedia("(prefers-reduced-motion: reduce)") - const updatePreference = () => { - setReducedMotion(query.matches) - if (query.matches) setPhase("idle") - } - - query.addEventListener("change", updatePreference) - return () => query.removeEventListener("change", updatePreference) - }, []) + if (reducedMotion) setPhase("idle") + }, [reducedMotion]) useEffect(() => { const handleVisibilityChange = () => { diff --git a/apps/web/src/ui/landing/orderbook-preview.tsx b/apps/web/src/ui/landing/orderbook-preview.tsx index 87784f87..3422884d 100644 --- a/apps/web/src/ui/landing/orderbook-preview.tsx +++ b/apps/web/src/ui/landing/orderbook-preview.tsx @@ -1,5 +1,6 @@ import { Link } from "@tanstack/react-router" import { useEffect, useRef, useState } from "react" +import { useReducedMotion } from "@/lib/use-reduced-motion" /* * Order book preview (OB-024). @@ -26,23 +27,6 @@ const SPARK = [12, 18, 15, 22, 19, 26, 24, 31, 28, 34, 30, 37] as const const TICK_MS = 2600 -function useReducedMotion(): boolean { - const [reduced, setReduced] = useState( - () => - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ) - - useEffect(() => { - const query = window.matchMedia("(prefers-reduced-motion: reduce)") - const update = () => setReduced(query.matches) - query.addEventListener("change", update) - return () => query.removeEventListener("change", update) - }, []) - - return reduced -} - /** Whether the panel is in the viewport and the page is visible. */ function usePreviewActive() { const ref = useRef(null) diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index a3f8dd39..2c0116c5 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -30,10 +30,7 @@ function DialogOverlay({