diff --git a/app/env/.env.production b/app/env/.env.production index 74a43254..c2924836 100644 --- a/app/env/.env.production +++ b/app/env/.env.production @@ -8,3 +8,6 @@ VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=production VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto VITE_RESYNC_URL=https://www.ivpn.net/en/account/ + +# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this. +VITE_IVPN_HOME_URL=https://www.ivpn.net diff --git a/app/env/.env.staging b/app/env/.env.staging index 88dc27ac..d69a2177 100644 --- a/app/env/.env.staging +++ b/app/env/.env.staging @@ -8,3 +8,6 @@ VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=staging VITE_DNS_SERVER_LOCATIONS=vm1:Quebec VITE_RESYNC_URL=https://staging.tamazaki.com/en/account/ + +# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this. +VITE_IVPN_HOME_URL=https://staging.tamazaki.com diff --git a/app/env/.env.test b/app/env/.env.test index 5cec5c3a..c250e461 100644 --- a/app/env/.env.test +++ b/app/env/.env.test @@ -9,3 +9,6 @@ VITE_SENTRY_RELEASE= VITE_SENTRY_ENVIRONMENT=test VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto VITE_RESYNC_URL=https://www.ivpn.net/en/account/ + +# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this. +VITE_IVPN_HOME_URL=https://staging.tamazaki.com diff --git a/app/index.html b/app/index.html index 2fa02940..340f93b7 100644 --- a/app/index.html +++ b/app/index.html @@ -13,7 +13,16 @@ - + + + + + + + + + + @@ -51,7 +60,7 @@ /* Respect reduced motion for any future fade transitions */ @media (prefers-reduced-motion: reduce) { .fade-enter-active, .fade-exit-active { transition: none !important; } } - modDNS + modDNS — Open-source DNS filtering by IVPN
diff --git a/app/public/fonts/IBMPlexMono-Regular.woff2 b/app/public/fonts/IBMPlexMono-Regular.woff2 new file mode 100644 index 00000000..0804aaff Binary files /dev/null and b/app/public/fonts/IBMPlexMono-Regular.woff2 differ diff --git a/app/public/fonts/VT323-Regular.woff2 b/app/public/fonts/VT323-Regular.woff2 new file mode 100644 index 00000000..fd760b5b Binary files /dev/null and b/app/public/fonts/VT323-Regular.woff2 differ diff --git a/app/public/og-image.png b/app/public/og-image.png new file mode 100644 index 00000000..09822e35 Binary files /dev/null and b/app/public/og-image.png differ diff --git a/app/src/App.tsx b/app/src/App.tsx index d91b6446..cc5d6172 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -27,6 +27,7 @@ const AccountPreferences = lazyWithRetry(() => import('@/pages/account_preferenc const MobileconfigPage = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigPage')); const MobileconfigDownload = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigDownload')); const HomeScreen = lazyWithRetry(() => import('./pages/home/HomeScreen')); +const Landing = lazyWithRetry(() => import('./pages/landing/Landing')); import { createBrowserRouter, RouterProvider, Navigate, Outlet, useLoaderData, useLocation, useNavigate, redirect, ScrollRestoration } from 'react-router-dom'; import { ThemeProvider } from "@/components/theme-provider" @@ -75,6 +76,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // Public route predicate (keep in sync with router public section) const isPublicPath = (p: string) => ( + p === '/' || p === '/login' || p === '/signup' || p === '/tos' || @@ -501,11 +503,19 @@ function ProtectedLayout() { } function RootIndexRedirect() { + // The public landing page is the canonical face of `/` for everyone — + // authenticated visitors see it too. The auth check below is read at the + // routing layer (with the localStorage belt-and-braces guard against stale + // React state vs. localStorage drift) and passed down so Landing can swap + // [01 LOGIN] for [01 DASHBOARD]. Landing itself stays a "dumb" component. + // + // The function name is kept for backwards-compat with the existing + // unit/e2e tests and the `export { RootIndexRedirect }` at the bottom of + // this file; it no longer actually redirects. const { isAuthenticated } = useAuth(); const localAuthed = typeof window !== 'undefined' ? localStorage.getItem(AUTH_KEY) === 'true' : isAuthenticated; - const target = isAuthenticated && localAuthed ? '/home' : '/login'; - - return ; + const authed = isAuthenticated && localAuthed; + return }>; } function SetupWithLoader() { diff --git a/app/src/__tests__/e2e/functional/auth.spec.ts b/app/src/__tests__/e2e/functional/auth.spec.ts index 24d4e09d..a0fc11c9 100644 --- a/app/src/__tests__/e2e/functional/auth.spec.ts +++ b/app/src/__tests__/e2e/functional/auth.spec.ts @@ -86,22 +86,50 @@ test.describe('@functional Authentication', () => { }); test.describe('@functional Root index redirects', () => { - test('unauthenticated visit to root redirects to /login', async ({ page }) => { + test('unauthenticated visit to root shows the landing page with [01 LOGIN]', async ({ page }) => { await registerMocks(page, { authenticated: false }); await page.goto('/'); - await expect.poll(async () => page.url()).toMatch(/\/login$/); + // Stay on / and render the landing chrome (CRT-themed marketing page). + await expect.poll(async () => page.url()).toMatch(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); + // Unauth nav surfaces the LOGIN entry point, not the dashboard shortcut. + await expect(page.getByRole('link', { name: '[01 LOGIN]' })).toBeVisible(); + await expect(page.getByRole('link', { name: '[01 DASHBOARD]' })).toHaveCount(0); }); - test('stale auth flag with expired session still redirects to /login', async ({ page }) => { + test('stale auth flag at root still shows the landing page', async ({ page }) => { + // `/` is now unconditionally the landing page. Even a stale AUTH_KEY=true + // in localStorage no longer triggers a redirect away from /. The 401 path + // through /home → /login only kicks in when the user actively visits a + // protected route (covered by the protected-route test above). await registerMocks(page, { authenticated: false }); await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); await page.goto('/'); - await expect.poll(async () => page.url()).toMatch(/\/login$/); + await expect.poll(async () => page.url()).toMatch(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); }); - test('valid session at root immediately navigates to /home', async ({ page }) => { + test('valid session at root stays on the landing page with [01 DASHBOARD]', async ({ page }) => { + // Authenticated visitors see the marketing landing page too. The + // [01 LOGIN] CTA in the nav swaps to [01 DASHBOARD] linking straight to + // /home; [01 LOGIN] should not be shown to a logged-in user. await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] }); + await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); await page.goto('/'); + await expect.poll(async () => page.url()).toMatch(/\/$/); + await expect(page.locator('.moddns-landing')).toBeVisible(); + const dashboardLink = page.getByRole('link', { name: '[01 DASHBOARD]' }); + await expect(dashboardLink).toBeVisible(); + await expect(dashboardLink).toHaveAttribute('href', '/home'); + await expect(page.getByRole('link', { name: '[01 LOGIN]' })).toHaveCount(0); + }); + + test('authenticated visit to /login redirects to /home', async ({ page }) => { + // Counterpart to the change above: authed users who click LOGIN from the + // landing page (or otherwise land on /login) should still bounce to /home. + await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] }); + await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY); + await page.goto('/login'); await expect.poll(async () => page.url()).toMatch(/\/home$/); }); }); diff --git a/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts b/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts index 1cb162f3..439fb5df 100644 --- a/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts +++ b/app/src/__tests__/e2e/layout/mobile-horizontal-overflow.spec.ts @@ -6,7 +6,7 @@ import { expectNoHorizontalOverflow } from '../utils/layoutAssertions'; // Runs only on explicitly mobile projects (chromium-mobile, iphone15pro) to keep suite lean. // Covers both public and protected routes + key interactions that could introduce overflow. -const PUBLIC_ROUTES = ['/login','/signup','/reset-password','/tos','/privacy','/faq']; +const PUBLIC_ROUTES = ['/','/login','/signup','/reset-password','/tos','/privacy','/faq']; const PROTECTED_ROUTES = ['/home','/setup','/settings','/blocklists','/custom-rules','/account-preferences','/mobileconfig','/query-logs']; // Interactions per route to surface latent overflow after dynamic UI changes. diff --git a/app/src/__tests__/unit/RootIndexRedirect.test.tsx b/app/src/__tests__/unit/RootIndexRedirect.test.tsx index 57d193c4..84320555 100644 --- a/app/src/__tests__/unit/RootIndexRedirect.test.tsx +++ b/app/src/__tests__/unit/RootIndexRedirect.test.tsx @@ -13,6 +13,14 @@ vi.mock('react-router-dom', async () => { }; }); +// Stub the lazy-loaded Landing component so it renders synchronously and +// surfaces its `isAuthenticated` prop in the DOM for assertion. +vi.mock('@/pages/landing/Landing', () => ({ + default: ({ isAuthenticated }: { isAuthenticated?: boolean }) => ( +
+ ), +})); + describe('RootIndexRedirect', () => { type AuthContextValue = React.ContextType; @@ -35,27 +43,38 @@ describe('RootIndexRedirect', () => { localStorage.clear(); }); - it('navigates to /home when both auth state and local storage are true', () => { + it('renders the landing page with isAuthenticated=true when both auth state and local storage agree', async () => { localStorage.setItem(AUTH_KEY, 'true'); renderWithAuth({ isAuthenticated: true }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/home'); + const landing = await screen.findByTestId('landing-page'); + expect(landing).toBeInTheDocument(); + expect(landing).toHaveAttribute('data-authed', 'true'); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); - it('navigates to /login when auth state is false', () => { + it('renders the landing page with isAuthenticated=false when auth state is false', async () => { localStorage.setItem(AUTH_KEY, 'true'); renderWithAuth({ isAuthenticated: false }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login'); + const landing = await screen.findByTestId('landing-page'); + expect(landing).toBeInTheDocument(); + expect(landing).toHaveAttribute('data-authed', 'false'); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); - it('falls back to /login when local storage flag is missing', () => { + it('renders the landing page with isAuthenticated=false when local storage flag is missing', async () => { localStorage.removeItem(AUTH_KEY); renderWithAuth({ isAuthenticated: true }); - expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login'); + const landing = await screen.findByTestId('landing-page'); + expect(landing).toBeInTheDocument(); + // Belt-and-braces guard: stale React state without localStorage backing + // does not flip the page into the authenticated UI. + expect(landing).toHaveAttribute('data-authed', 'false'); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); }); }); diff --git a/app/src/assets/landing/dashboard-screenshot-mobile.png b/app/src/assets/landing/dashboard-screenshot-mobile.png new file mode 100644 index 00000000..a56bf8b3 Binary files /dev/null and b/app/src/assets/landing/dashboard-screenshot-mobile.png differ diff --git a/app/src/assets/landing/dashboard-screenshot.png b/app/src/assets/landing/dashboard-screenshot.png new file mode 100644 index 00000000..355f8c98 Binary files /dev/null and b/app/src/assets/landing/dashboard-screenshot.png differ diff --git a/app/src/pages/custom_rules/RuleComposer.tsx b/app/src/pages/custom_rules/RuleComposer.tsx index 0a01d98f..7f9067c4 100644 --- a/app/src/pages/custom_rules/RuleComposer.tsx +++ b/app/src/pages/custom_rules/RuleComposer.tsx @@ -320,7 +320,7 @@ export function RuleComposer({ Input: CustomInput, }} classNamePrefix="rule-composer" - placeholder="Paste or type domains, IPs, or ASNs" + placeholder="Domain, IP, or ASN" value={tokens} inputValue={inputValue} onChange={handleChange} diff --git a/app/src/pages/landing/Landing.tsx b/app/src/pages/landing/Landing.tsx new file mode 100644 index 00000000..daf60137 --- /dev/null +++ b/app/src/pages/landing/Landing.tsx @@ -0,0 +1,439 @@ +import { Link } from 'react-router-dom'; +import SystemClock from './SystemClock'; +import TopologyDiagram from './TopologyDiagram'; +import { LINKS } from './links'; +import dashboardScreenshot from '@/assets/landing/dashboard-screenshot.png'; +import dashboardScreenshotMobile from '@/assets/landing/dashboard-screenshot-mobile.png'; +import './landing.css'; + +type LandingProps = { + /** + * When true, the page swaps the [01 LOGIN] CTA for [01 DASHBOARD] (linking + * to /home). Authenticated users still see the marketing page at `/`; + * this prop simply gives them a direct path back into the app. + * + * Defaults to `false` so the component stays trivially renderable in + * tests, Storybook, etc. — the auth-aware caller (RootIndexRedirect) is + * responsible for passing the real value. + */ + isAuthenticated?: boolean; +}; + +export default function Landing({ isAuthenticated = false }: LandingProps) { + return ( +
+
+ + {/* NAV */} +
+
+ + {isAuthenticated ? ( + + [01 DASHBOARD] + + ) : ( + + [01 LOGIN] + + )} + + + + [02 START] + + + + + [03 PRIVACY] + + + + + [04 FAQ] + + +
+
+ STATUS: ONLINE + SYS.TIME: +
+
+ + {/* HERO */} +
+
MODDNS_LOADED
+
+
+

+ RESIST_DNS_SURVEILLANCE_ +

+

+ Block ads and trackers at the DNS level using modDNS, an open-source + service with configurable blocklists and custom rules. +

+ +
+

+ Your DNS queries reveal which domains you visit. ISPs can monitor them, and + websites use them to share data with third-party ad and tracking networks. + While using your VPN provider's DNS resolver and tracker-blocker tool can + address this issue, modDNS offers more visibility and better control over + what is blocked. +

+
+ {/* TODO(landing): pull v.1.0.6 from package.json or a build-time constant */} + v.1.0.6 +
+ + {/* PRODUCT SCREENSHOT */} +
+
[ MODDNS_DASHBOARD ]
+
+ + {/* Mobile-tuned screenshot at ≤768 px (smaller, narrower aspect). + Desktop falls back to the wide screenshot below. */} + + modDNS Dashboard — Custom Rules Interface + +
+
+ + {/* WHAT YOU CAN DO */} +
+
DNS_FILTER_MODULES
+

+ Granular DNS Filtering +

+
+
+

01. COMBINE BLOCKLISTS

+

+ Start with Basic protection or Comprehensive/Restrictive presets. Enable + individual lists from Hagezi, OISD, and others. Block specific services + (e.g. Facebook, Google, Amazon) or categories (e.g. adult content, + gambling). +

+
+
+

02. DEFINE CUSTOM RULES

+

+ Override blocklists with allowlist entries for domains you don't want + blocked. Add denylist entries for domains not covered by existing lists. + Use wildcard patterns for wider coverage. +

+
+
+

03. CREATE DNS PROFILES

+

+ Configure different filtering rules for work and personal devices. Each + profile gets a unique identifier for DNS setup. Supports DNS-over-HTTPS, + DNS-over-TLS, and DNS-over-QUIC. +

+
+
+

04. MONITOR QUERIES

+

+ Query logging disabled by default. When enabled, set retention period + and review blocked and allowed requests by device. Download logs for + analysis. +

+
+
+
+ + {/* TECHNICAL SPECIFICATIONS */} +
+
[ TECHNICAL_SPECIFICATIONS ]
+
+
+

// DNS_PROTOCOLS

+
    +
  • DNS-over-HTTPS (DoH)Port 443
  • +
  • DNS-over-TLS (DoT)Port 853
  • +
  • DNS-over-QUIC (DoQ)Port 853
  • +
  • DNSSECEnabled
  • +
+
+
+

// PLATFORM_SUPPORT

+
    +
  • System-wideWin/Mac/Linux/iOS/Android
  • +
  • BrowserAll supported
  • +
  • IVPN AppsCustom DNS
  • +
  • Router/FirewallSupported
  • +
+
+
+

// PRIVACY_ARCHITECTURE

+
    +
  • IP LoggingNone
  • +
  • Query LoggingOff by Default
  • +
  • Device IDOptional
  • +
  • RetentionOptional (1H-30D)
  • +
+
+
+
+ + {/* VERIFIABLE PRIVACY */} +
+
TRUST_SIG
+

Verifiable Privacy

+
+
+

ACCOUNTABLE OPERATORS

+

+ Built by the public team behind IVPN, with a 15-year history in + operating privacy services. +

+ + [ MEET THE TEAM ] + +
+
+

OPEN SOURCE

+

+ The entire modDNS project is open-source. Our implementation is public + and available for review. +

+ + [ REVIEW CODE ] + +
+
+

SECURITY AUDIT

+

+ Independently audited by Cure53 in 2025. Full report available to + review. +

+ + [ READ THE REPORT ] + +
+
+

NO TRACKING

+

+ By default we do not log DNS queries, timestamps, IP addresses and + device identifiers. +

+ [ REVIEW OUR POLICIES ] +
+
+
+ + {/* SERVICE LIMITATIONS */} +
+
+ [ SERVICE_LIMITATIONS ] +
+
    +
  • + modDNS is a DNS resolver, not a comprehensive privacy solution. It filters + DNS queries but does not encrypt other network traffic. +
  • +
  • + Aggressive blocklists may break legitimate services. Start with Basic + protection and adjust as needed. +
  • +
  • + Query logging is off by default. Enable only if you need visibility for + troubleshooting. +
  • +
  • + Not designed for protection against targeted surveillance or advanced + persistent threats. +
  • +
  • + Blocklists update every 1-3 hours. We can't guarantee new malicious domains + are blocked immediately. +
  • +
+
+ + {/* UNLINKED ACCESS */} +
+
+
UNLINKED_SVC
+

UNLINKED ACCESS

+

+ Additional services in the IVPN privacy stack do not receive or store your + IVPN account ID. There is no shared identity layer connecting your accounts + across services. +

+
    +
  • + Subscription access is verified through token-derived hashes, not + account identifiers +
  • +
  • + Ongoing subscription sync requires no knowledge of which IVPN account + you hold +
  • +
  • + Does not prevent all forms of cross-service correlation — see + documentation for the full threat model +
  • +
+

+ {/* TODO(landing): wrap "Read more about Unlinked Access" in + + once the IVPN explainer page is published. Until then the prose stays + unlinked and only the source-code link is active. */} + Read more about Unlinked Access and review the{' '} + + code + + . +

+
+
+ SVC_TOPOLOGY + +
+
+ + {/* GET ACCESS */} +
+
PLAN_INIT
+

GET ACCESS TO MODDNS

+

+ modDNS is included in IVPN Plus and Pro Suite. No standalone plan is available. + Visit{' '} + + ivpn.net + {' '} + for pricing and account setup. +

+
+
+
+
+
IVPN_PLUS
+
$80/YEAR
+
+ + [ START ] + +
+
+
    +
  • IVPN / 5 Devices
  • +
  • Mailx
  • +
  • modDNS
  • +
+
+
+
+
+
IVPN_PRO_SUITE
+
$100/YEAR
+
+ + [ START ] + +
+
+
    +
  • IVPN / 10 Devices
  • +
  • Mailx
  • +
  • modDNS
  • +
  • Portmaster Pro
  • +
+
+
+
+ + {/* FOOTER */} +
+ modDNS ::{' '} + {isAuthenticated ? ( + DASHBOARD + ) : ( + LOGIN + )} + {' '}::{' '} + FAQ + {' '}::{' '} + PRIVACY + {' '}:: EOF. CONNECTION TERMINATED. +
+ +
+
+ ); +} diff --git a/app/src/pages/landing/SystemClock.tsx b/app/src/pages/landing/SystemClock.tsx new file mode 100644 index 00000000..128cf287 --- /dev/null +++ b/app/src/pages/landing/SystemClock.tsx @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +function fmt(d: Date): string { + return d.toISOString().split('T')[1].split('.')[0]; +} + +export default function SystemClock() { + const [time, setTime] = useState(() => fmt(new Date())); + + useEffect(() => { + const id = setInterval(() => setTime(fmt(new Date())), 1000); + return () => clearInterval(id); + }, []); + + return {time}; +} diff --git a/app/src/pages/landing/TopologyDiagram.tsx b/app/src/pages/landing/TopologyDiagram.tsx new file mode 100644 index 00000000..ba0a13d9 --- /dev/null +++ b/app/src/pages/landing/TopologyDiagram.tsx @@ -0,0 +1,108 @@ +// Unlinked Access topology SVG — ported verbatim from the marketing handover HTML. +// Filter IDs (#gn2, #rd2) are namespaced to avoid clashing with any future +// hero-flow SVG that might use #gn / #rd. +export default function TopologyDiagram() { + return ( + + + + + + + + + + + + + + + + + + + + + + + {/* ═══ GREEN GROUP ═══ */} + + + User + + + + + IVPN + + + + + modDNS + + + Mailx + + + Portmaster + + + {/* ═══ UA GROUP ═══ */} + + + UA + + + + + + + ); +} diff --git a/app/src/pages/landing/landing.css b/app/src/pages/landing/landing.css new file mode 100644 index 00000000..1830e9fc --- /dev/null +++ b/app/src/pages/landing/landing.css @@ -0,0 +1,666 @@ +/* + * modDNS landing page — scoped CRT/phosphor terminal aesthetic. + * + * All rules are scoped to .moddns-landing so the global app design system + * (Tailwind, shadcn/ui, --shadcn-ui-* tokens) is unaffected. The :root + * variables defined here use phosphor/CRT-specific names and will not + * collide with existing app variables. + * + * Self-hosted fonts (VT323, IBM Plex Mono) live in app/public/fonts/ and + * are loaded via the @font-face declarations below. Falls back to the + * generic monospace stack while the woff2 files transfer (font-display: swap). + */ + +@font-face { + font-family: 'VT323'; + src: url('/fonts/VT323-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'IBM Plex Mono'; + src: url('/fonts/IBMPlexMono-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +.moddns-landing { + --bg: #030408; + --phosphor: #4AF6C3; + --phosphor-dim: #154535; + --phosphor-glow: rgba(74, 246, 195, 0.4); + --alert: #FF3366; + --font-display: 'VT323', monospace; + --font-data: 'IBM Plex Mono', monospace; + --grid-gap: 1.5rem; + + background-color: var(--bg); + color: var(--phosphor); + font-family: var(--font-data); + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; + position: relative; + min-height: 100vh; + width: 100%; +} + +.moddns-landing *, +.moddns-landing *::before, +.moddns-landing *::after { + box-sizing: border-box; +} + +.moddns-landing ::selection { + background: var(--phosphor); + color: var(--bg); +} + +.moddns-landing::before { + content: " "; + display: block; + position: fixed; + top: 0; left: 0; bottom: 0; right: 0; + background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.08) 50%), + linear-gradient(90deg, rgba(255, 0, 0, 0.01), rgba(0, 255, 0, 0.005), rgba(0, 0, 255, 0.01)); + z-index: 998; + background-size: 100% 2px, 3px 100%; + pointer-events: none; + opacity: 0.4; +} + +.moddns-landing::after { + content: " "; + display: block; + position: fixed; + top: 0; left: 0; bottom: 0; right: 0; + background: radial-gradient(circle, rgba(0,0,0,0) 75%, rgba(0,0,0,0.6) 100%); + z-index: 999; + pointer-events: none; +} + +@keyframes flicker { + 0% { opacity: 0.95; } + 100% { opacity: 1; } +} + +.moddns-landing .glitch-block { + position: relative; +} + +.moddns-landing .container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.moddns-landing h1, +.moddns-landing h2, +.moddns-landing h3 { + font-family: var(--font-display); + font-weight: normal; + text-transform: uppercase; + /* glow reduced to 40% of original 8px → 3px */ + text-shadow: 0 0 3px var(--phosphor-glow); + letter-spacing: 1px; + margin: 0; +} + +.moddns-landing p { + margin: 0; +} + +.moddns-landing .blink { + animation: blinker 1s step-start infinite; +} + +@keyframes blinker { 50% { opacity: 0; } } + +.moddns-landing .window { + border: 1px solid var(--phosphor-dim); + padding: 1.5rem; + position: relative; + background: rgba(3, 4, 8, 0.8); +} + +.moddns-landing .window::before { + content: '+'; + position: absolute; top: -7px; left: -4px; + background: var(--bg); padding: 0 2px; color: var(--phosphor-dim); +} + +.moddns-landing .window::after { + content: '+'; + position: absolute; bottom: -7px; right: -4px; + background: var(--bg); padding: 0 2px; color: var(--phosphor-dim); +} + +.moddns-landing .window-sealed::after { content: none; } + +.moddns-landing .window-title { + position: absolute; + top: -10px; + left: 1rem; + background: var(--bg); + padding: 0 0.5rem; + font-family: var(--font-data); + font-size: 12px; + color: var(--phosphor); +} + +.moddns-landing .meta-data { + font-size: 10px; + color: var(--phosphor-dim); + position: absolute; +} + +.moddns-landing .meta-tr { top: 0.5rem; right: 0.5rem; } +.moddns-landing .meta-bl { bottom: 0.5rem; left: 0.5rem; } + +.moddns-landing ul { list-style: none; padding: 0; margin: 0; } + +.moddns-landing li::before { + content: "> "; + color: var(--phosphor-dim); +} + +.moddns-landing a.btn, +.moddns-landing .btn { + display: inline-block; + padding: 0.25rem 1rem; + border: 1px solid var(--phosphor); + color: var(--phosphor); + text-decoration: none; + text-transform: uppercase; + cursor: pointer; + transition: all 0.1s; + background: transparent; + font-family: var(--font-data); +} + +.moddns-landing a.btn:hover, +.moddns-landing .btn:hover { + background: var(--phosphor); + color: var(--bg); + /* glow reduced to 40% of original 10px → 4px */ + box-shadow: 0 0 4px var(--phosphor-glow); +} + +.moddns-landing .sys-nav { + display: flex; + justify-content: space-between; + border-bottom: 1px dashed var(--phosphor-dim); + padding-bottom: 0.5rem; + font-size: 14px; +} + +.moddns-landing .sys-nav span { margin-right: 1rem; } + +.moddns-landing .hero { + padding: 4rem 2rem 1.5rem; + text-align: left; +} + +.moddns-landing .hero h1 { + font-size: 4rem; + margin-top: 2rem; + margin-bottom: 2.5rem; + line-height: 1; +} + +.moddns-landing .hero p { + font-size: 1.2rem; + max-width: 600px; + margin-bottom: 3.5rem; +} + +.moddns-landing .hero .btn-group { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + justify-content: flex-start; +} + +.moddns-landing .hero .hero-explain { + font-size: 0.9rem; + color: #888; + max-width: calc(100% - 30px); + margin-top: 2rem; + border-top: 1px dotted var(--phosphor-dim); + padding-top: 1rem; + text-align: left; +} + +.moddns-landing .hero-diagram { + width: 100%; + height: 390px; + border: 1px solid var(--phosphor-dim); + position: relative; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ); +} + +.moddns-landing .hero-diagram svg { + width: 100%; + height: 100%; +} + +.moddns-landing .screenshot-container { + position: relative; + border: 2px solid var(--phosphor-dim); + overflow: hidden; +} + +.moddns-landing .screenshot-container img { + display: block; + width: 100%; + height: auto; +} + +.moddns-landing .screenshot-container::after { + content: ""; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.06) 50%); + background-size: 100% 3px; + pointer-events: none; + opacity: 0.5; +} + +.moddns-landing .features-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .feature-item { + border-left: 1px solid var(--phosphor-dim); + padding-left: 1rem; +} + +.moddns-landing .feature-item h3 { + font-size: 1.5rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid var(--phosphor-dim); + display: inline-block; + padding-bottom: 2px; +} + +.moddns-landing .specs-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .spec-col ul li { + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + border-bottom: 1px dotted var(--phosphor-dim); +} + +.moddns-landing .spec-col ul li::before { content: none; } + +.moddns-landing .spec-col ul li span:last-child { color: #fff; } + +.moddns-landing .trust-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .trust-box { + padding: 1.5rem; +} + +.moddns-landing .trust-box h3 { + font-size: 1.8rem; + margin-bottom: 0.75rem; +} + +.moddns-landing .trust-box p { + margin-bottom: 1rem; + color: #aaa; +} + +.moddns-landing .trust-box a { + color: var(--phosphor); + text-decoration: none; + font-size: 12px; +} + +.moddns-landing .trust-box a:hover { + /* glow reduced to 40% of original 8px → 3px */ + text-shadow: 0 0 3px var(--phosphor-glow); +} + +/* Two-column grid with an explicit 3-row track and column-flow. Items 1-3 + land in the left column, items 4-5 in the right. Deterministic — does not + rely on the browser's column-balancing algorithm, which was overriding the + earlier `break-before: column` hint. */ +.moddns-landing .constraints-list { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: repeat(3, auto); + grid-auto-flow: column; + gap: 0.5rem 2rem; +} + +.moddns-landing .constraints-list li { + color: #aaa; +} + +.moddns-landing .constraints-list li::before { + color: var(--alert); + content: "X "; +} + +.moddns-landing .suite-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + align-items: center; +} + +.moddns-landing .vector-diagram { + width: 100%; + height: 300px; + border: 1px solid var(--phosphor-dim); + position: relative; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 19px, + rgba(21, 69, 53, 0.2) 20px + ); +} + +.moddns-landing .vector-diagram svg { + width: 100%; + height: 100%; +} + +/* Desktop offset: pushes the diagram down so its rows align with the + text-column body copy instead of the section title. Removed on mobile (see + ≤768px override) where the diagram stacks below the text and any extra top + margin only widens the gap. */ +.moddns-landing .suite-section .vector-diagram { margin-top: 50px; } + +.moddns-landing .node { + fill: var(--bg); + stroke: var(--phosphor); + stroke-width: 1; +} + +.moddns-landing .link { + stroke: var(--phosphor); + stroke-width: 1; + stroke-dasharray: 4; + animation: dash 60s linear infinite; +} + +@keyframes dash { + to { stroke-dashoffset: -1000; } +} + +.moddns-landing .section-title { + font-family: var(--font-data); + font-size: 1rem; + color: #2a7a55; + margin-bottom: 1rem; + letter-spacing: 2px; +} + +.moddns-landing .section-title::before, +.moddns-landing .section-title::after { + content: "---"; + margin: 0 0.5rem; +} + +.moddns-landing .pricing-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--grid-gap); +} + +.moddns-landing .plan-box { + border: 1px solid var(--phosphor-dim); + padding: 1.5rem; + position: relative; + background: rgba(3, 4, 8, 0.8); +} + +.moddns-landing .plan-box .plan-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.moddns-landing .plan-box .plan-name { + font-family: var(--font-data); + font-size: 13px; + color: var(--phosphor); + margin-bottom: 0.25rem; +} + +.moddns-landing .plan-box .plan-price { + font-family: var(--font-display); + font-size: 1.2rem; + /* glow reduced to 40% of original 8px → 3px */ + text-shadow: 0 0 3px var(--phosphor-glow); +} + +.moddns-landing .plan-box .plan-price span { + font-size: 1.2rem; +} + +.moddns-landing .plan-box .plan-divider { + border: none; + border-top: 1px dashed var(--phosphor-dim); + margin: 1rem 0; +} + +.moddns-landing .plan-box ul li { + margin-bottom: 0.5rem; + color: #fff; +} + +.moddns-landing .cmd-label { + display: inline-block; + padding: 0.15rem 0.5rem; + border: 1px solid var(--phosphor-dim); + font-size: 12px; + color: var(--phosphor-dim); + margin-bottom: 1.5rem; +} + +@media (max-width: 768px) { + .moddns-landing .container { + padding: 1rem; + gap: 1.25rem; + } + + /* Stack the two sys-nav rows; status/clock falls below the link row, right-justified. */ + .moddns-landing .sys-nav { + flex-direction: column; + gap: 0.5rem; + align-items: stretch; + } + .moddns-landing .sys-nav > div { + display: flex; + flex-wrap: wrap; + column-gap: 0.75rem; + row-gap: 0.25rem; + } + /* STATUS: ONLINE + SYS.TIME are decorative chrome — hide on mobile to give + the link grid the full width and keep the nav focused on actions. */ + .moddns-landing .sys-nav > div:last-child { display: none; } + .moddns-landing .sys-nav span { margin-right: 0; } + + /* Top nav link group becomes a 2×2 grid of bordered tap targets: + row 1: [01 LOGIN] [02 START], row 2: [03 PRIVACY] [04 FAQ]. Each link + gets a real frame (matching the dashed sys-nav rule colour) plus 44px + minimum height for comfortable tapping. */ + .moddns-landing .sys-nav > div:first-child { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + .moddns-landing .sys-nav > div:first-child span { + margin: 0; + display: block; + } + .moddns-landing .sys-nav > div:first-child a { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 0.5rem; + border: 1px solid var(--phosphor-dim); + min-height: 44px; + text-align: center; + } + + /* Hero: smaller padding and fluid h1 that allows the underscore-separated + word to wrap (which is on-brand for the snake_case identifier feel). */ + .moddns-landing .hero { + padding: 2rem 1rem 1.25rem; + } + .moddns-landing .hero h1 { + font-size: clamp(1.75rem, 8vw, 4rem); + margin-top: 1.25rem; + margin-bottom: 1.5rem; + overflow-wrap: anywhere; + } + .moddns-landing .hero p { + font-size: 1rem; + margin-bottom: 2rem; + } + + /* All section grids collapse to single column. */ + .moddns-landing .features-grid, + .moddns-landing .specs-grid, + .moddns-landing .trust-grid, + .moddns-landing .suite-section, + .moddns-landing .pricing-grid { + grid-template-columns: 1fr; + } + .moddns-landing .constraints-list { + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-auto-flow: row; + } + + /* Vector diagram: hug content rather than holding a fixed 300px slot. */ + .moddns-landing .vector-diagram { + aspect-ratio: 450 / 308; + height: auto; + } + /* Tight stacking in the Unlinked Access section: drop the desktop 50px + diagram offset and shrink the section gap from 3rem to 1.5rem so the + text and diagram sit close together when stacked in a single column. */ + .moddns-landing .suite-section { gap: 1.5rem; } + .moddns-landing .suite-section .vector-diagram { margin-top: 0; } + + /* Verifiable Privacy: each trust-box's bottom link ([ MEET THE TEAM ], + [ REVIEW CODE ], [ READ THE REPORT ], [ REVIEW OUR POLICIES ]) becomes a + full-width bordered tap target so the section reads as four equally- + weighted CTAs instead of inline footnote links. */ + .moddns-landing .trust-box a { + display: flex; + align-items: center; + justify-content: center; + margin-top: 0.75rem; + padding: 0.75rem 1rem; + border: 1px solid var(--phosphor-dim); + min-height: 44px; + text-align: center; + font-size: 14px; + } +} + +@media (max-width: 480px) { + .moddns-landing .container { padding: 0.75rem; } + + .moddns-landing .hero { padding: 1.5rem 0.75rem 1rem; } + /* Bigger headline because it's the page's main statement, and the two + intentional halves (RESIST_DNS_ / SURVEILLANCE_) each fit on their own + line at this width. */ + .moddns-landing .hero h1 { + font-size: clamp(3rem, 16vw, 4.5rem); + line-height: 1.1; + } + .moddns-landing .hero h1 .hero-h1-part { display: block; } + + /* Hero CTAs: stack vertically and span the full width of the hero so each + button is a generous tap target on phones. */ + .moddns-landing .hero .btn-group { + flex-direction: column; + gap: 0.5rem; + } + .moddns-landing .hero .btn-group .btn { + width: 100%; + text-align: center; + } + + /* Spec rows: label on top, value indented below — preserves the data-row + terminal feel without the right-side overflow on long values like + "Win/Mac/Linux/iOS/Android". */ + .moddns-landing .spec-col ul li { + flex-direction: column; + gap: 0.15rem; + align-items: flex-start; + padding-bottom: 0.4rem; + } + .moddns-landing .spec-col ul li span:last-child { + padding-left: 0.75rem; + border-left: 1px dotted var(--phosphor-dim); + } + + /* Section title decorations are too noisy at this width; keep the bare label. */ + .moddns-landing .section-title::before, + .moddns-landing .section-title::after { content: none; } + + /* Plan-box header: allow the [ START ] button to wrap below the price block. */ + .moddns-landing .pricing-grid .plan-box .plan-header { + flex-wrap: wrap; + gap: 0.75rem; + } +} + +/* Touch-device tap targets — meets WCAG AA 44x44 minimum. */ +@media (hover: none) and (pointer: coarse) { + .moddns-landing a.btn, + .moddns-landing .btn { + padding: 0.5rem 1rem; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } +} diff --git a/app/src/pages/landing/links.ts b/app/src/pages/landing/links.ts new file mode 100644 index 00000000..5041c819 --- /dev/null +++ b/app/src/pages/landing/links.ts @@ -0,0 +1,19 @@ +// Centralised external URLs surfaced on the marketing landing page. +// +// IVPN paths derive from VITE_IVPN_HOME_URL (set in app/env/.env.*) so prod / +// staging / local can repoint coherently with one env-file edit. GitHub URLs +// are env-invariant — the canonical repos don't change between deployments. + +const ivpnHome = (import.meta.env.VITE_IVPN_HOME_URL || 'https://www.ivpn.net') + .replace(/\/+$/, ''); // tolerate trailing slash in env value + +export const LINKS = Object.freeze({ + ivpnHome, + pricing: `${ivpnHome}/pricing/`, + ivpnTeam: `${ivpnHome}/en/team/`, + auditReport: `${ivpnHome}/resources/IVP-08-report.pdf`, + moddnsRepo: 'https://github.com/ivpn/moddns', + unlinkedRepo: 'https://github.com/ivpn/unlinked-access', +}); + +export type LandingLinks = typeof LINKS; diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 11f02fe2..b7a4a592 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_IVPN_HOME_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}