Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -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)$).*)'],
};
178 changes: 124 additions & 54 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html>,
* 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;
Expand Down Expand Up @@ -65,57 +75,65 @@

/* 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 ─────────────────────────────────────────────────────────── */
/*
* 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 <html>. 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 ────────────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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;
}
}

Expand Down
Loading
Loading