diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx
index b2875c5b..e3ddd14c 100644
--- a/app/(marketing)/page.tsx
+++ b/app/(marketing)/page.tsx
@@ -17,6 +17,7 @@ import { HowItWorks } from "./_sections/how-it-works";
import { WalletsTokens } from "@/components/sections/wallets-token";
import { AnimatedBackground } from "@/components/ui/animated-background";
import Navbar from "./_components/navbar";
+import { LiveTicker } from "@/app/components/LiveTicker";
import KpiStrip from "./_sections/kpi-strip";
import { CTA } from "./_sections/cta";
import { useParallax } from "@/hooks/use-parallax";
@@ -34,6 +35,7 @@ export default function MarketingRoute() {
+
diff --git a/app/components/LiveTicker.tsx b/app/components/LiveTicker.tsx
new file mode 100644
index 00000000..dbd579c0
--- /dev/null
+++ b/app/components/LiveTicker.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+
+interface TickerEvent {
+ id: string;
+ text: string;
+ timestamp: string;
+}
+
+const mockEvents: TickerEvent[] = [
+ { id: "1", text: "User 0x4F... placed 500 XLM on 'ETH > $4K'", timestamp: "1m ago" },
+ { id: "2", text: "New Market created: US Elections 2028", timestamp: "3m ago" },
+ { id: "3", text: "Market 'Super Bowl LIX' resolved to 'Chiefs'", timestamp: "5m ago" },
+ { id: "4", text: "User 0xA1... claimed 1,200 XLM reward", timestamp: "12m ago" },
+ { id: "5", text: "Dispute opened on 'SpaceX Mars Mission'", timestamp: "15m ago" },
+ { id: "6", text: "User 0x9B... predicted Yes on 'Stellar Protocol 22'", timestamp: "18m ago" },
+];
+
+export function LiveTicker() {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ setPrefersReducedMotion(mediaQuery.matches);
+
+ const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
+
+ if (typeof mediaQuery.addEventListener === 'function') {
+ mediaQuery.addEventListener("change", handler);
+ return () => mediaQuery.removeEventListener("change", handler);
+ } else {
+ mediaQuery.addListener(handler);
+ return () => mediaQuery.removeListener(handler);
+ }
+ }, []);
+
+ if (prefersReducedMotion) {
+ return (
+
+
+
+
+
+ Live Activity:
+ {mockEvents[0].text}
+ ({mockEvents[0].timestamp})
+
+
+ );
+ }
+
+ return (
+
+ {/* Gradient edges for smooth fade out */}
+
+
+
+
+ {mockEvents.map((event) => (
+
+
+
+
+
+ {event.text}
+ {event.timestamp}
+
+ ))}
+
+
+ {mockEvents.map((event) => (
+
+
+
+
+
+ {event.text}
+ {event.timestamp}
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/__tests__/LiveTicker.test.tsx b/app/components/__tests__/LiveTicker.test.tsx
new file mode 100644
index 00000000..0900fe75
--- /dev/null
+++ b/app/components/__tests__/LiveTicker.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from "@testing-library/react";
+import { LiveTicker } from "../LiveTicker";
+import "@testing-library/jest-dom";
+
+const setReducedMotion = (matches: boolean) => {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: query === "(prefers-reduced-motion: reduce)" ? matches : false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ }));
+};
+
+describe("LiveTicker", () => {
+ beforeEach(() => {
+ setReducedMotion(false);
+ });
+
+ describe("Normal Motion", () => {
+ it("renders the continuous marquee container when reduced motion is disabled", () => {
+ render();
+
+ const tickerContainer = screen.getByTestId("live-ticker-marquee");
+ expect(tickerContainer).toBeInTheDocument();
+ expect(tickerContainer).toHaveAttribute("aria-hidden", "true");
+
+ const marqueeElements = document.querySelectorAll(".animate-marquee");
+ expect(marqueeElements.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("displays the mock events", () => {
+ render();
+ const events = screen.getAllByText(/User 0x4F\.\.\. placed 500 XLM/i);
+ expect(events.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe("Reduced Motion", () => {
+ it("falls back to a static accessible region when reduced motion is enabled", () => {
+ setReducedMotion(true);
+ render();
+
+ expect(screen.queryByTestId("live-ticker-marquee")).not.toBeInTheDocument();
+
+ const region = screen.getByRole("region", { name: "Recent Market Activity" });
+ expect(region).toBeInTheDocument();
+
+ const eventText = screen.getByText(/User 0x4F\.\.\. placed 500 XLM/i);
+ expect(eventText).toBeInTheDocument();
+
+ const staticLabel = screen.getByText(/Live Activity:/i);
+ expect(staticLabel).toBeInTheDocument();
+ });
+ });
+});
diff --git a/components/disputes/shared/CountdownTimer.tsx b/components/disputes/shared/CountdownTimer.tsx
index 0b47aac6..d4f94221 100644
--- a/components/disputes/shared/CountdownTimer.tsx
+++ b/components/disputes/shared/CountdownTimer.tsx
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
interface CountdownTimerProps {
deadline: Date;
label?: string;
+
}
interface TimeLeft {
@@ -13,6 +14,7 @@ interface TimeLeft {
hours: number;
minutes: number;
seconds: number;
+
}
function computeTimeLeft(deadline: Date): TimeLeft | null {
@@ -55,6 +57,7 @@ function buildAnnouncement(t: TimeLeft): string {
if (parts.length === 0) return 'Less than one second remaining';
return `${parts.join(', ')} remaining`;
+
}
/**
@@ -119,6 +122,7 @@ export function CountdownTimer({ deadline, label }: CountdownTimerProps) {
const [expired, setExpired] = useState(() => {
if (!isValidDate(deadline)) return false;
return deadline.getTime() <= Date.now();
+
});
// The string the aria-live region currently shows. Updates only at coarse
@@ -210,9 +214,12 @@ export function CountdownTimer({ deadline, label }: CountdownTimerProps) {
>
{visibleLabel}
-
- {announcement}
-
+ {/* Avoid duplicate text and unnecessary live region updates when reduced motion is preferred */}
+ {!prefersReducedMotion && (
+
+ {announcement}
+
+ )}
);
}
diff --git a/components/disputes/shared/__tests__/CountdownTimer.test.tsx b/components/disputes/shared/__tests__/CountdownTimer.test.tsx
index be349ca8..ffe8ff27 100644
--- a/components/disputes/shared/__tests__/CountdownTimer.test.tsx
+++ b/components/disputes/shared/__tests__/CountdownTimer.test.tsx
@@ -200,6 +200,7 @@ describe('CountdownTimer', () => {
const liveRegion = container.querySelector('[aria-live="polite"]');
expect(liveRegion?.textContent).toBe('Deadline passed');
});
+
});
describe('reduced motion', () => {
@@ -222,6 +223,7 @@ describe('CountdownTimer', () => {
const countdownEl = screen.getByText(/seconds remaining/);
expect(countdownEl).toHaveClass('text-destructive');
expect(countdownEl).not.toHaveClass('animate-pulse');
+
});
});
});
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 1dacc435..e6b46d30 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -124,11 +124,16 @@ const config: Config = {
to: {
height: '0'
}
+ },
+ 'marquee': {
+ from: { transform: 'translateX(0)' },
+ to: { transform: 'translateX(-100%)' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
- 'accordion-up': 'accordion-up 0.2s ease-out'
+ 'accordion-up': 'accordion-up 0.2s ease-out',
+ 'marquee': 'marquee 30s linear infinite'
}
}
},