diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..03acd2f --- /dev/null +++ b/middleware.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Locale routing & SSR-aware i18n middleware (issue #494). + * + * Routing strategy: cookie-based locale resolution WITHOUT a URL path prefix. + * Rationale: the existing App Router tree (src/app/**) is not segmented by + * `[locale]`, and introducing a prefix would require restructuring every route + * plus rewriting all internal links. A cookie + `Accept-Language` negotiation + * keeps the current route structure intact while still resolving the locale + * during SSR (the resolved locale is forwarded to the app via the + * `x-vortex-locale` request header, read with `headers()` in `layout.tsx`). + * + * Resolution order (highest priority first): + * 1. explicit URL prefix (`/es/...`) or `?lang=` query param + * 2. `vortex-locale` cookie + * 3. `Accept-Language` negotiation + * 4. default locale + * + * This middleware also coordinates with the CSP middleware by preserving the + * existing `Content-Security-Policy` response header when present, and sets + * `Vary: Accept-Language, Cookie` so caches key on the negotiated inputs. + */ + +export const SUPPORTED_LOCALES = ['en', 'es'] as const; +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = 'en'; +export const LOCALE_COOKIE = 'vortex-locale'; +export const LOCALE_HEADER = 'x-vortex-locale'; + +const LOCALE_PREFIX_RE = /^\/([a-z]{2})(?:-[A-Za-z]{2,4})?(?=\/|$)/; + +function isSupported(value: string | null | undefined): value is Locale { + if (!value) return false; + return (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +/** + * Normalise a raw locale tag to a supported base locale. + * e.g. `es-MX` -> `es`, `EN-us` -> `en`, `fr` -> null. + */ +export function normalizeLocale(raw: string | null | undefined): Locale | null { + if (!raw) return null; + const base = raw.trim().toLowerCase().split('-')[0]; + return isSupported(base) ? base : null; +} + +/** + * Pure Accept-Language negotiation (RFC 9110 §12.5.4). + * Returns the best supported locale or null when nothing matches. + */ +export function negotiateLocale( + acceptLanguage: string | null | undefined, + supported: readonly string[] = SUPPORTED_LOCALES, + fallback: Locale = DEFAULT_LOCALE, +): Locale { + if (!acceptLanguage) return fallback; + + const parsed = acceptLanguage + .split(',') + .map((part) => { + const [tag, ...params] = part.trim().split(';'); + const qParam = params.find((p) => p.trim().startsWith('q=')); + const q = qParam ? Number.parseFloat(qParam.split('=')[1]) : 1; + return { tag: tag.trim().toLowerCase(), q: Number.isFinite(q) ? q : 0 }; + }) + .filter((entry) => entry.tag.length > 0 && entry.q > 0) + .sort((a, b) => b.q - a.q); + + for (const { tag } of parsed) { + const base = tag.split('-')[0]; + if ((supported as readonly string[]).includes(base)) { + return base as Locale; + } + } + + return fallback; +} + +/** + * Resolve the locale for a request following the documented priority order. + * Also returns the pathname with any locale prefix stripped so downstream + * routing is unaffected by the prefix form. + */ +export function resolveLocale(request: NextRequest): { + locale: Locale; + pathname: string; + fromPrefix: boolean; +} { + const { pathname, searchParams } = request.nextUrl; + + const prefixMatch = pathname.match(LOCALE_PREFIX_RE); + if (prefixMatch) { + const candidate = normalizeLocale(prefixMatch[1]); + if (candidate) { + const stripped = pathname.slice(prefixMatch[0].length) || '/'; + return { locale: candidate, pathname: stripped, fromPrefix: true }; + } + } + + const queryLocale = normalizeLocale(searchParams.get('lang')); + if (queryLocale) { + return { locale: queryLocale, pathname, fromPrefix: false }; + } + + const cookieLocale = normalizeLocale(request.cookies.get(LOCALE_COOKIE)?.value); + if (cookieLocale) { + return { locale: cookieLocale, pathname, fromPrefix: false }; + } + + const negotiated = negotiateLocale(request.headers.get('accept-language')); + return { locale: negotiated, pathname, fromPrefix: false }; +} + +export function middleware(request: NextRequest) { + const { locale, pathname, fromPrefix } = resolveLocale(request); + + const requestHeaders = new Headers(request.headers); + requestHeaders.set(LOCALE_HEADER, locale); + + const url = request.nextUrl.clone(); + url.pathname = pathname; + + const response = NextResponse.rewrite(url, { + request: { headers: requestHeaders }, + }); + + // Persist the resolved preference so subsequent requests skip negotiation. + const existing = request.cookies.get(LOCALE_COOKIE)?.value; + if (existing !== locale) { + response.cookies.set(LOCALE_COOKIE, locale, { + path: '/', + maxAge: 60 * 60 * 24 * 365, + sameSite: 'lax', + }); + } + + // Cache keys must vary on the negotiated inputs. + response.headers.set('Vary', 'Accept-Language, Cookie'); + + // Coordinate with CSP middleware: preserve an existing policy if set. + const csp = request.headers.get('content-security-policy'); + if (csp) { + response.headers.set('Content-Security-Policy', csp); + } + + // Expose the resolved locale for debugging/SEO tooling. + response.headers.set(LOCALE_HEADER, locale); + + if (fromPrefix) { + response.headers.set('x-vortex-locale-prefix', '1'); + } + + return response; +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|gif|webp|ico|css|js|map|txt|xml)$).*)'], +}; diff --git a/src/app/globals.css b/src/app/globals.css index 464688e..4a644c4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -23,8 +23,18 @@ * 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. + * + * The effective theme is driven by the `data-theme` attribute on , + * which is set before paint by the inline blocking script in layout.tsx + * (see src/lib/theme.ts). `data-theme="light"` selects the light palette; + * `data-theme="dark"` (or no attribute) selects the dark palette. The + * light palette is defined exactly once below — it is NOT duplicated inside + * a prefers-color-scheme media query, so a user override always wins. */ - :root { + :root, + html[data-theme="dark"] { + color-scheme: dark; + /* Surfaces */ --vx-ink: #080C14; --vx-base: #0C1120; @@ -65,6 +75,10 @@ /* Focus ring */ --vx-focus-ring: rgba(76, 235, 168, 0.5); + + /* Motion durations — zeroed under reduce (see below) */ + --vx-motion-duration: 1.2s; + --vx-motion-duration-fast: 0.15s; } /* ── Light theme ─────────────────────────────────────────────────────────── */ @@ -72,50 +86,54 @@ * 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. + * + * Selected only when the user (or the pre-paint script) sets + * data-theme="light" on . Defined once here — never inside a + * prefers-color-scheme media query — so an explicit user choice is honoured. */ - @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); - } + html[data-theme="light"] { + color-scheme: light; + + /* 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 ────────────────────────────────────────────────────────────────── */ @@ -241,7 +259,7 @@ .intent-flow-line { stroke-dasharray: 6 4; stroke-dashoffset: 10; - animation: flow 1.2s linear infinite; + animation: flow var(--vx-motion-duration) linear infinite; } @keyframes flow { @@ -250,7 +268,20 @@ } } +/* ── Reduced motion ──────────────────────────────────────────────────────── */ +/* + * Motion is disabled when either the OS requests it (prefers-reduced-motion) + * or the user picks "reduce" in SettingsPanel (html[data-motion="reduce"]). + * Durations collapse to 0.01ms and infinite loops are stopped so no animation + * keeps running (WCAG 2.2.2). Essential progress indicators stay visible as + * static marks rather than disappearing. + */ @media (prefers-reduced-motion: reduce) { + :root { + --vx-motion-duration: 0.01ms; + --vx-motion-duration-fast: 0.01ms; + } + *, *::before, *::after { @@ -261,6 +292,11 @@ } } +html[data-motion="reduce"] { + --vx-motion-duration: 0.01ms; + --vx-motion-duration-fast: 0.01ms; +} + html[data-motion="reduce"] *, html[data-motion="reduce"] *::before, html[data-motion="reduce"] *::after { @@ -270,16 +306,50 @@ html[data-motion="reduce"] *::after { scroll-behavior: auto !important; } -html[data-motion="allow"] *, -html[data-motion="allow"] *::before, -html[data-motion="allow"] *::after { - scroll-behavior: smooth; -} +/* ── Print: force the light palette ──────────────────────────────────────── */ +/* + * Printing on a dark palette wastes ink and is often unreadable. When the + * user prints, force the light token values regardless of the active theme. + */ +@media print { + :root, + html[data-theme="dark"], + html[data-theme="light"] { + color-scheme: light; + + --vx-ink: #FFFFFF; + --vx-base: #FFFFFF; + --vx-surface: #FFFFFF; + --vx-card: #FFFFFF; + --vx-border: rgba(0, 0, 0, 0.15); + --vx-line: rgba(0, 0, 0, 0.1); + + --vx-sage: #0A6849; + --vx-sage-dim: #0A6849; + --vx-sage-bg: rgba(15, 168, 113, 0.08); + + --vx-lav: #4C1D95; + --vx-lav-dim: #4C1D95; + --vx-lav-bg: rgba(124, 58, 237, 0.08); + + --vx-amber: #92400E; + --vx-rose: #991B1B; + + --vx-text: #000000; + --vx-muted: #374151; + --vx-dim: #9CA3AF; + + --vx-body-bg: #FFFFFF; + --vx-body-radial1: transparent; + --vx-body-radial2: transparent; + + --vx-scrollbar-thumb: rgba(0, 0, 0, 0.2); + --vx-selection-bg: rgba(15, 168, 113, 0.2); + --vx-focus-ring: rgba(15, 168, 113, 0.5); + } -/* Safe-area padding for notched/dynamic-island iOS devices */ -@layer utilities { - .pb-safe { - padding-bottom: env(safe-area-inset-bottom, 0px); + body { + background-image: none; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e69de29..15d9db9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -0,0 +1,175 @@ +import type { Metadata, Viewport } from "next"; +import { headers } from "next/headers"; +import "./globals.css"; +import { WalletHydrator } from "@/components/WalletHydrator"; +import { ToastViewport } from "@/components/ToastViewport"; +import { IntentStatusWatcher } from "@/components/IntentStatusWatcher"; +import { I18nProvider } from "@/lib/i18n/I18nProvider"; +import { DEFAULT_LOCALE, LOCALE_HEADER, isLocale, type Locale } from "@/lib/i18n"; + +const TITLE = "Vortex | Cross-chain Swaps via Stellar"; +const DESCRIPTION = + "Swap any token from any chain directly to Stellar. Intent-based cross-chain liquidity protocol — no bridges, no wrapped tokens."; + +// The canonical site URL — used in absolute OG image URLs. +// Falls back to localhost for local dev; set NEXT_PUBLIC_SITE_URL in production. +const SITE_URL = + process.env["NEXT_PUBLIC_SITE_URL"]?.replace(/\/$/, "") ?? + "http://localhost:3000"; + +// Storage key shared with the theme setting in SettingsPanel and the +// no-flash bootstrap script below. Kept in sync with src/lib/theme.ts. +const THEME_STORAGE_KEY = "vortex:theme"; + +// Inline, render-blocking script that resolves the effective theme and sets +// `data-theme` on before first paint, preventing a flash of the wrong +// theme (FOUC). It is CSP-nonce compatible: when a nonce is provided via the +// `x-nonce` request header it is attached to the `; +} + +/** + * The blocking bootstrap body. Kept as a string so it can be inlined into the + * server-rendered `` and executed before first paint. + */ +export const THEME_INIT_SOURCE = `(function(){try{var p=localStorage.getItem(${JSON.stringify( + THEME_STORAGE_KEY, +)});if(p!=="light"&&p!=="dark"){p="system";}var d=p==="dark"||(p==="system"&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches);var t=d?"dark":"light";var r=document.documentElement;r.setAttribute("data-theme",t);r.style.colorScheme=t;var m=document.querySelector('meta[name="theme-color"]');if(!m){m=document.createElement("meta");m.name="theme-color";document.head.appendChild(m);}m.content=d?${JSON.stringify( + THEME_COLOR.dark, +)}:${JSON.stringify(THEME_COLOR.light)};}catch(e){document.documentElement.setAttribute("data-theme","light");}})();`;