diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..309caf3 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Nav } from "@/components/Nav"; +import { Footer } from "@/components/Footer"; + +interface ErrorPageProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function Error({ error, reset }: ErrorPageProps) { + useEffect(() => { + // Log to an error reporting service in production. + console.error(error); + }, [error]); + + return ( +
+
+ ); +} diff --git a/src/app/explore/[id]/error.tsx b/src/app/explore/[id]/error.tsx new file mode 100644 index 0000000..26b213c --- /dev/null +++ b/src/app/explore/[id]/error.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Nav } from "@/components/Nav"; +import { Footer } from "@/components/Footer"; + +interface ErrorPageProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function IntentDetailError({ error, reset }: ErrorPageProps) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+
+ ); +} diff --git a/src/app/explore/[id]/loading.tsx b/src/app/explore/[id]/loading.tsx new file mode 100644 index 0000000..0353f08 --- /dev/null +++ b/src/app/explore/[id]/loading.tsx @@ -0,0 +1,28 @@ +import { SkeletonBlock, SkeletonDetailCard } from "@/components/Skeleton"; + +/** + * Route-segment loading boundary for /explore/[id]. + * Uses SkeletonDetailCard to match the intent detail layout. + */ +export default function IntentDetailLoading() { + return ( +
+ {/* Nav placeholder */} + + ); +} diff --git a/src/app/explore/error.tsx b/src/app/explore/error.tsx new file mode 100644 index 0000000..73a28ec --- /dev/null +++ b/src/app/explore/error.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Nav } from "@/components/Nav"; +import { Footer } from "@/components/Footer"; + +interface ErrorPageProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function ExploreError({ error, reset }: ErrorPageProps) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+
+ ); +} diff --git a/src/app/explore/loading.tsx b/src/app/explore/loading.tsx new file mode 100644 index 0000000..2122465 --- /dev/null +++ b/src/app/explore/loading.tsx @@ -0,0 +1,40 @@ +import { SkeletonBlock, SkeletonCard } from "@/components/Skeleton"; + +/** + * Route-segment loading boundary for /explore. + * Mirrors the filter bar + intent table layout. + */ +export default function ExploreLoading() { + return ( +
+ {/* Nav placeholder */} + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index b957339..fa0812a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,6 +5,7 @@ @layer base { * { box-sizing: border-box; } + /* ── Font stacks ─────────────────────────────────────────────────────────── */ :root { --font-geist-sans: "Inter", system-ui, sans-serif; --font-geist-mono: "JetBrains Mono", ui-monospace, monospace; @@ -12,27 +13,140 @@ html { -webkit-font-smoothing: antialiased; } + /* ── Dark theme (default) ───────────────────────────────────────────────── */ + /* + * The :root block defines the dark-mode palette. The light-mode overrides + * below flip every variable to its light counterpart. Because Tailwind + * resolves all vx-* color tokens to var(--vx-*), every component in the + * tree automatically adapts without any className changes. + */ + :root { + /* Surfaces */ + --vx-ink: #080C14; + --vx-base: #0C1120; + --vx-surface: #111827; + --vx-card: #161E2E; + --vx-border: rgba(255, 255, 255, 0.07); + --vx-line: rgba(255, 255, 255, 0.04); + + /* Primary: sage green */ + --vx-sage: #4CEBA8; + --vx-sage-dim: #2A8A62; + --vx-sage-bg: rgba(76, 235, 168, 0.08); + + /* Accent: lavender */ + --vx-lav: #A78BFA; + --vx-lav-dim: #5B3F8C; + --vx-lav-bg: rgba(167, 139, 250, 0.08); + + /* State */ + --vx-amber: #F5A623; + --vx-rose: #F87171; + + /* Text */ + --vx-text: #E8EDF5; + --vx-muted: #6B7A8E; + --vx-dim: #374151; + + /* Body background (used in the body rule below) */ + --vx-body-bg: #080C14; + --vx-body-radial1: rgba(76, 235, 168, 0.06); + --vx-body-radial2: rgba(167, 139, 250, 0.04); + + /* Scrollbar */ + --vx-scrollbar-thumb: rgba(255, 255, 255, 0.08); + + /* Selection */ + --vx-selection-bg: rgba(76, 235, 168, 0.25); + + /* Focus ring */ + --vx-focus-ring: rgba(76, 235, 168, 0.5); + } + + /* ── Light theme ─────────────────────────────────────────────────────────── */ + /* + * All surfaces invert: ink → near-white, card → white, surface → light grey. + * Text colour inverts similarly. The sage/lavender hues stay saturated but + * their backgrounds and dims shift so they read well on white. + */ + @media (prefers-color-scheme: light) { + :root { + /* Surfaces */ + --vx-ink: #FFFFFF; + --vx-base: #F5F7FA; + --vx-surface: #EFF1F5; + --vx-card: #FFFFFF; + --vx-border: rgba(0, 0, 0, 0.08); + --vx-line: rgba(0, 0, 0, 0.05); + + /* Primary: sage green (slightly deeper for contrast on white) */ + --vx-sage: #0FA871; + --vx-sage-dim: #0A6849; + --vx-sage-bg: rgba(15, 168, 113, 0.08); + + /* Accent: lavender */ + --vx-lav: #7C3AED; + --vx-lav-dim: #4C1D95; + --vx-lav-bg: rgba(124, 58, 237, 0.08); + + /* State */ + --vx-amber: #D97706; + --vx-rose: #DC2626; + + /* Text */ + --vx-text: #0F172A; + --vx-muted: #64748B; + --vx-dim: #CBD5E1; + + /* Body background */ + --vx-body-bg: #F5F7FA; + --vx-body-radial1: rgba(15, 168, 113, 0.04); + --vx-body-radial2: rgba(124, 58, 237, 0.03); + + /* Scrollbar */ + --vx-scrollbar-thumb: rgba(0, 0, 0, 0.12); + + /* Selection */ + --vx-selection-bg: rgba(15, 168, 113, 0.2); + + /* Focus ring */ + --vx-focus-ring: rgba(15, 168, 113, 0.5); + } + } + + /* ── Body ────────────────────────────────────────────────────────────────── */ body { - background: #080C14; + background-color: var(--vx-body-bg); background-image: - radial-gradient(ellipse 80% 60% at 50% -20%, rgba(76,235,168,0.06) 0%, transparent 70%), - radial-gradient(ellipse 60% 40% at 80% 80%, rgba(167,139,250,0.04) 0%, transparent 60%); + radial-gradient(ellipse 80% 60% at 50% -20%, var(--vx-body-radial1) 0%, transparent 70%), + radial-gradient(ellipse 60% 40% at 80% 80%, var(--vx-body-radial2) 0%, transparent 60%); background-attachment: fixed; min-height: 100vh; + color: var(--vx-text); } + /* ── Scrollbar ───────────────────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 3px; height: 3px; } ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + ::-webkit-scrollbar-thumb { + background: var(--vx-scrollbar-thumb); + border-radius: 2px; + } + + /* ── Selection ───────────────────────────────────────────────────────────── */ + ::selection { background: var(--vx-selection-bg); color: inherit; } - ::selection { background: rgba(76,235,168,0.25); color: inherit; } - :focus-visible { outline: 1.5px solid rgba(76,235,168,0.5); outline-offset: 2px; } + /* ── Focus ring ──────────────────────────────────────────────────────────── */ + :focus-visible { + outline: 1.5px solid var(--vx-focus-ring); + outline-offset: 2px; + } } @layer components { .card { @apply bg-vx-card border border-vx-border rounded-xl overflow-hidden; - background-image: linear-gradient(160deg, rgba(76,235,168,0.025) 0%, transparent 50%); + background-image: linear-gradient(160deg, color-mix(in srgb, var(--vx-sage) 2.5%, transparent) 0%, transparent 50%); } .card-sm { @@ -78,8 +192,8 @@ /* Swap button gradient */ .btn-swap { - background: linear-gradient(135deg, #4CEBA8 0%, #38BDF8 100%); - color: #080C14; + background: linear-gradient(135deg, var(--vx-sage) 0%, #38BDF8 100%); + color: var(--vx-ink); font-weight: 700; font-size: 0.9375rem; padding: 0.875rem; @@ -93,11 +207,15 @@ .btn-swap:hover { filter: brightness(1.08); } .btn-swap:active { transform: scale(0.99); } .btn-swap:disabled { - background: linear-gradient(135deg, rgba(76,235,168,0.25) 0%, rgba(56,189,248,0.25) 100%); - color: rgba(8,12,20,0.5); + background: linear-gradient(135deg, color-mix(in srgb, var(--vx-sage) 25%, transparent) 0%, rgba(56,189,248,0.25) 100%); + color: color-mix(in srgb, var(--vx-ink) 50%, transparent); cursor: not-allowed; } +/* ── Light-mode .btn-swap text fix ───────────────────────────────────────── */ +/* On dark: ink is #080C14 (nearly black) — fine on the sage gradient. + On light: ink is #FFFFFF — also fine, gives white text on the green button. */ + /* Flow animation for intent pipeline */ .intent-flow-line { stroke-dasharray: 6 4; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 543ea82..c901624 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -82,7 +82,7 @@ export const viewport: Viewport = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + + {/* Nav placeholder */} + + ); +} diff --git a/src/app/my-intents/error.tsx b/src/app/my-intents/error.tsx new file mode 100644 index 0000000..299dc73 --- /dev/null +++ b/src/app/my-intents/error.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Nav } from "@/components/Nav"; +import { Footer } from "@/components/Footer"; + +interface ErrorPageProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function MyIntentsError({ error, reset }: ErrorPageProps) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+
+ ); +} diff --git a/src/app/my-intents/loading.tsx b/src/app/my-intents/loading.tsx new file mode 100644 index 0000000..5e12f20 --- /dev/null +++ b/src/app/my-intents/loading.tsx @@ -0,0 +1,39 @@ +import { SkeletonBlock, SkeletonCard } from "@/components/Skeleton"; + +/** + * Route-segment loading boundary for /my-intents. + * Mirrors the filter bar + paginated intent table layout. + */ +export default function MyIntentsLoading() { + return ( +
+ {/* Nav placeholder */} + + ); +} diff --git a/src/app/solve/[address]/error.tsx b/src/app/solve/[address]/error.tsx new file mode 100644 index 0000000..0ba9bcc --- /dev/null +++ b/src/app/solve/[address]/error.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Nav } from "@/components/Nav"; +import { Footer } from "@/components/Footer"; + +interface ErrorPageProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function SolverProfileError({ error, reset }: ErrorPageProps) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+
+ ); +} diff --git a/src/app/solve/[address]/loading.tsx b/src/app/solve/[address]/loading.tsx new file mode 100644 index 0000000..e21bfcf --- /dev/null +++ b/src/app/solve/[address]/loading.tsx @@ -0,0 +1,28 @@ +import { SkeletonBlock, SkeletonCard, SkeletonDetailCard } from "@/components/Skeleton"; + +/** + * Route-segment loading boundary for /solve/[address]. + * Mirrors the solver detail header + fill history layout. + */ +export default function SolverProfileLoading() { + return ( +
+ {/* Nav placeholder */} + + ); +} diff --git a/src/app/solve/error.tsx b/src/app/solve/error.tsx new file mode 100644 index 0000000..313c28c --- /dev/null +++ b/src/app/solve/error.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Nav } from "@/components/Nav"; +import { Footer } from "@/components/Footer"; + +interface ErrorPageProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function SolveError({ error, reset }: ErrorPageProps) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+
+ ); +} diff --git a/src/app/solve/loading.tsx b/src/app/solve/loading.tsx new file mode 100644 index 0000000..2277b3e --- /dev/null +++ b/src/app/solve/loading.tsx @@ -0,0 +1,46 @@ +import { SkeletonBlock, SkeletonCard } from "@/components/Skeleton"; + +/** + * Route-segment loading boundary for /solve. + * Mirrors the hero + tabs + leaderboard/intents layout. + */ +export default function SolveLoading() { + return ( +
+ {/* Nav placeholder */} + + ); +} diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx index 6b18bde..2e2cdd1 100644 --- a/src/components/ActivityFeed.tsx +++ b/src/components/ActivityFeed.tsx @@ -1,9 +1,11 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useIntentFeed } from "@/hooks/useIntentFeed"; +import { useTranslation } from "@/lib/i18n/I18nProvider"; import { timeAgo } from "@/lib/time"; import type { FeedItem } from "@/lib/types"; +import { SkeletonCard } from "./Skeleton"; const CHAIN_COLOR: Record = { ethereum: "#627EEA", base: "#0052FF", polygon: "#8247E5", @@ -13,14 +15,65 @@ const CHAIN_COLOR: Record = { /** Maximum number of activity items shown in the feed. */ const FEED_LIMIT = 6; -export function ActivityFeed() { +/** Debounce delay in ms before announcing new items to assistive technology. */ +const ANNOUNCE_DELAY_MS = 1500; + +// ─── Skeleton ───────────────────────────────────────────────────────────────── + +function FeedSkeleton({ count = 3 }: { count?: number }) { + return ; +} + +// ─── ActivityFeedView ───────────────────────────────────────────────────────── +// Pure-presentational component that receives already-fetched data. +// Exported so Storybook stories and tests can drive it directly. + +export interface ActivityFeedViewProps { + items: FeedItem[]; + isLoading: boolean; + error?: Error | null; + isLive: boolean; +} + +export function ActivityFeedView({ items, isLoading, error, isLive }: ActivityFeedViewProps) { const { t } = useTranslation(); - const { items, isLoading, error, isLive } = useIntentFeed(); - /** - * Memoized slice of the most recent feed items. - * Avoids recreating the array on every render when `items` reference is stable. - */ + // ── Screen-reader announcement ──────────────────────────────────────────── + // We track how many items were present on the *previous* render so we can + // detect new arrivals. The initial snapshot must never trigger an + // announcement — we only announce incremental additions after mount. + const prevCountRef = useRef(null); + const [announcement, setAnnouncement] = useState(""); + const pendingNewRef = useRef(0); + const timerRef = useRef | null>(null); + + useEffect(() => { + // Skip the first render (initial snapshot). + if (prevCountRef.current === null) { + prevCountRef.current = items.length; + return; + } + + const delta = items.length - prevCountRef.current; + prevCountRef.current = items.length; + + if (delta <= 0) return; + + // Accumulate across rapid arrivals; reset the debounce window each time. + pendingNewRef.current += delta; + if (timerRef.current !== null) clearTimeout(timerRef.current); + + timerRef.current = setTimeout(() => { + const count = pendingNewRef.current; + pendingNewRef.current = 0; + setAnnouncement(count === 1 ? "1 new fill" : `${count} new fills`); + }, ANNOUNCE_DELAY_MS); + }, [items.length]); + + // Cleanup timer on unmount. + useEffect(() => () => { if (timerRef.current !== null) clearTimeout(timerRef.current); }, []); + + // ── Visible items ───────────────────────────────────────────────────────── const visibleItems = useMemo(() => items.slice(0, FEED_LIMIT), [items]); if (isLoading && items.length === 0) { @@ -29,11 +82,18 @@ export function ActivityFeed() { return (
-
{announcement}
+ {/* Live region: assistive technology reads this when the text changes. */} +
+ {announcement} +
+ + {/* Live / Polling indicator */}
+ + {/* Error / empty states */} {error && items.length === 0 ? (
{t("activityFeed.error.unavailable")} @@ -43,13 +103,21 @@ export function ActivityFeed() { {t("activityFeed.empty")}
) : null} + + {/* Feed rows */} {visibleItems.map((item) => { const color = CHAIN_COLOR[item.srcChain] ?? "#8B8B93"; return ( -
-