diff --git a/docs/superpowers/plans/2026-04-24-ctamodal-hero-ribbon.md b/docs/superpowers/plans/2026-04-24-ctamodal-hero-ribbon.md new file mode 100644 index 00000000..e5488e2d --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-ctamodal-hero-ribbon.md @@ -0,0 +1,983 @@ +# CtaModal Hero + Vertical Stepper Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild `src/components/ui/CtaModal.tsx` visually per the hero + vertical stepper spec (2026-04-24). Logic frozen. Pure emerald palette. Dominant "Try Free" hero button + 3 compact instruction cards. Dim-by-default cards cascade active on click. VPNOrbit demoted to atmosphere with acknowledgment flash. New Aurora backlight layer. Violet fully removed. + +**Architecture:** Single file refactor — all sub-components stay inside `src/components/ui/CtaModal.tsx` matching existing project convention. No new files outside this one. Logic (copyAndLaunch, useCtaModal, escape, scroll lock, AbortError handling, file-input fallback, focus management) copied verbatim. Visual layers restructured: ModalShell → AuroraBacklight → NoiseLayer → 2-col grid (VPNOrbit aside | right content). Right content: header / title / meta / HeroButton + LaunchedChip overlay / SectionDivider / StepCard × 3 with connectors / footer. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript strict, Tailwind v4, motion/react, lucide-react. Existing `MagneticButton` and `BorderTrail` reused. No new dependencies. + +**Spec reference:** `docs/superpowers/specs/2026-04-24-ctamodal-hero-ribbon-design.md` — sections A1-A4 contain absolute-grade values. + +**Verify commands** (project convention, no test runner): +- `npm run typecheck` — `tsc --noEmit` +- `npm run lint` — eslint +- `npm run build` — full Next.js build, 35 routes +- `npm run check` — runs all three above + +**Commit format:** Conventional commits enforced by pre-commit hook. Valid types: feat, fix, refactor, docs, style, perf, chore. Use `feat(cta): ...` or `refactor(cta): ...` for these changes. + +--- + +## File Structure + +Only one file changes: + +- **Modify:** `src/components/ui/CtaModal.tsx` — all edits happen here + +Sub-components added / replaced inside this file: +- `AuroraBacklight` (new) +- `HeroButton` (replaces `TryFreeCard`) +- `LaunchedChip` (new) +- `SectionDivider` (new) +- `StepCard` (replaces `StepRow`) +- `StepperConnector` (new) + +Sub-components modified in place: +- `MeshOrbs` — violet orb → emerald-700 orb +- `VPNOrbit` — filter, retimed pulses, radial bleed, acknowledgment flash +- `KeyCap` — restyled flat for instruction context (removes 3D depth) + +Sub-components removed: +- `TryFreeCard` (absorbed into HeroButton + content row) +- `ConfettiBurst` (not in new design) +- `ProgressRing` (not used after HeroButton simplification) + +State changes: +- `revealedIndex` state + cascade useEffect → removed; cascade driven by motion variants `staggerChildren` +- `copied`, `copying`, `platform` states preserved + +--- + +## Task 1: Remove violet from MeshOrbs + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx:543-598` (the `MeshOrbs` function) + +- [ ] **Step 1: Replace the violet orb background class** + +Locate the second `motion.span` inside `MeshOrbs` (around line 573). Change: + +```tsx +className="pointer-events-none absolute right-[10%] bottom-[10%] size-[380px] rounded-full bg-violet-500/[0.18] blur-[140px]" +``` + +To: + +```tsx +className="pointer-events-none absolute right-[10%] bottom-[10%] size-[380px] rounded-full bg-emerald-700/25 blur-[140px]" +``` + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS (both clean) + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "style(cta): replace violet mesh orb with emerald-700" +``` + +--- + +## Task 2: Add radial bleed + retime pulse rings in VPNOrbit + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx:459-537` (the `VPNOrbit` function) + +- [ ] **Step 1: Add filter wrapper + radial bleed, retime pulse rings** + +Replace the entire `VPNOrbit` body (lines 459-537) with: + +```tsx +function VPNOrbit({ reduceMotion, acknowledged }: { reduceMotion: boolean; acknowledged: boolean }) { + return ( +
+ {/* radial bleed — soft emerald halo behind everything */} + + + {/* outer pulse ring — master beat 4.8s */} + + + {/* inner pulse ring — phase offset 1.6s (rolling wave) */} + + + {/* orbit ring (dashed, static) */} + + + {/* orbiting icons */} + {ORBIT_ICONS.map(({ Icon, angle, delay }, i) => ( + + + + + + ))} + + {/* central shield — breathing at master beat 4.8s, acknowledgment flash on success */} + + + + + + {/* bottom meta */} +
+
+ + + secured tunnel + +
+ + wireguard · sha-256 + +
+
+ ); +} +``` + +- [ ] **Step 2: Update the call site to pass `acknowledged` prop** + +Find where `` is called (around line 312). Change to: + +```tsx + +``` + +- [ ] **Step 3: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "refactor(cta): demote VPNOrbit to atmosphere + acknowledgment flash" +``` + +--- + +## Task 3: Add AuroraBacklight component + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — add new function near `NoiseLayer` (around line 600) + +- [ ] **Step 1: Add AuroraBacklight function** + +After the `NoiseLayer` function definition, add: + +```tsx +/* ══════════════════════════════════════════════════════════════ + AURORA BACKLIGHT — slow-rotating emerald atmosphere inside shell +═══════════════════════════════════════════════════════════════ */ + +function AuroraBacklight({ reduceMotion }: { reduceMotion: boolean }) { + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "feat(cta): add AuroraBacklight atmosphere layer" +``` + +--- + +## Task 4: Mount AuroraBacklight inside modal shell + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — inside the modal shell JSX (around line 293-294, right after the shell opens and before ``) + +- [ ] **Step 1: Add AuroraBacklight mount** + +Locate the line: +```tsx + +``` + +Replace it with: +```tsx + + +``` + +(AuroraBacklight renders at z-0, NoiseLayer visually sits above it; both behind content which lives inside the 2-col grid.) + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "feat(cta): mount AuroraBacklight inside modal shell" +``` + +--- + +## Task 5: Add HeroButton component + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — add near the end of the file (after `ProgressRing` or wherever makes sense). Place this above `TryFreeCard` so later when we delete TryFreeCard everything still parses. + +- [ ] **Step 1: Add HeroButton function** + +Append near the bottom, before the `TryFreeCard` function: + +```tsx +/* ══════════════════════════════════════════════════════════════ + HERO BUTTON — dominant primary action, dims but stays on click +═══════════════════════════════════════════════════════════════ */ + +function HeroButton({ + state, + onClick, +}: { + state: "idle" | "copying" | "copied"; + onClick: () => void; +}) { + const disabled = state !== "idle"; + const isBusy = state === "copying"; + + const handleClick = () => { + if (disabled) return; + onClick(); + }; + + return ( + + + {/* inner ring */} + + {/* conic shimmer on ring */} + {!disabled && ( + + )} + + + Try Free + + + + + ); +} +``` + +- [ ] **Step 2: Add the `cta-spin` keyframe** + +Check if `src/app/globals.css` already has a generic `spin` animation usable. If not, add at the bottom of `src/app/globals.css`: + +```css +@keyframes cta-spin { + from { + --a: 0deg; + } + to { + --a: 360deg; + } +} + +@property --a { + syntax: ""; + inherits: false; + initial-value: 0deg; +} +``` + +Actually — `@property` has limited browser support in SSR. Fallback: use a static conic gradient without animation for Task 5; the breathing glow (Task-based pulse shadow) carries the "alive" feel. Skip the keyframe and mask shimmer for now — we'll add it in Task 7 after verifying the rest. Remove the conic shimmer span from the HeroButton code above if needed. + +**Decision for simplicity:** Remove the conic-shimmer `` from the HeroButton implementation in Step 1 above. Keep only the static inner ring + BorderTrail + static emerald gradient + breathing shadow. This simplifies to: + +Replace the HeroButton body inner JSX (after MagneticButton opens) with: + +```tsx + {/* inner ring */} + + + + Try Free + + +``` + +**Do not edit globals.css in this task.** Revisit advanced shimmer later if desired. + +- [ ] **Step 3: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "feat(cta): add HeroButton primary CTA" +``` + +--- + +## Task 6: Add LaunchedChip component + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — add below `HeroButton` + +- [ ] **Step 1: Add LaunchedChip function** + +Append after the `HeroButton` function: + +```tsx +function LaunchedChip() { + return ( + + + Installer launched + + ); +} +``` + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "feat(cta): add LaunchedChip confirmation pill" +``` + +--- + +## Task 7: Add SectionDivider component + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — add below `LaunchedChip` + +- [ ] **Step 1: Add SectionDivider function** + +Append after `LaunchedChip`: + +```tsx +function SectionDivider() { + return ( + + + + Затем в Проводнике: + + + ); +} +``` + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "feat(cta): add SectionDivider for hero/stepper separation" +``` + +--- + +## Task 8: Add StepCard component (replaces StepRow) + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — add below `SectionDivider` (do NOT delete StepRow yet; we'll swap the call site in Task 11) + +- [ ] **Step 1: Add StepCard function** + +Append after `SectionDivider`: + +```tsx +function StepCard({ + step, + index, + active, +}: { + step: StepSpec; + index: number; + active: boolean; +}) { + return ( + + {/* number chip */} + + {`0${index + 1}`} + + + {/* label + desc */} +
+

+ {step.label} +

+

+ {step.desc} +

+
+ + {/* keycaps */} +
+ {step.keys.map((key, i) => ( +
+ {i > 0 && ( + + + )} + 1 ? "min-w-[32px]" : "min-w-[24px]", + active + ? "border-emerald-400/30 bg-emerald-400/10 text-emerald-200" + : "border-white/[0.08] bg-white/[0.02] text-zinc-400", + )} + > + {key} + +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "feat(cta): add StepCard vertical instruction card" +``` + +--- + +## Task 9: Add StepperConnector component + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — add below `StepCard` + +- [ ] **Step 1: Add StepperConnector function** + +Append after `StepCard`: + +```tsx +function StepperConnector({ active }: { active: boolean }) { + return ( + + ); +} +``` + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "feat(cta): add StepperConnector vertical line between cards" +``` + +--- + +## Task 10: Update stepsListVariants for cascade timing + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx:123-128` (the `stepsListVariants` definition) + +- [ ] **Step 1: Adjust stepsListVariants** + +Locate: +```tsx +const stepsListVariants: Variants = { + hidden: {}, + visible: { + transition: { delayChildren: 0.44, staggerChildren: 0.08 }, + }, +}; +``` + +Replace with: +```tsx +const stepsListVariants: Variants = { + hidden: {}, + visible: { + transition: { delayChildren: 0.64, staggerChildren: 0.08 }, + }, +}; +``` + +Rationale: SectionDivider takes a slot in the stagger now, so overall delay before cards slides slightly. + +- [ ] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "chore(cta): retune stepper cascade delay" +``` + +--- + +## Task 11: Replace content in the right column — hero + stepper + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — the right panel JSX between the `file meta row` and the `hairline` (roughly lines 375-410) + +- [ ] **Step 1: Replace the `` block and non-windows notice** + +Locate the block starting at the comment `{/* ── steps (CTA card first, then keystrokes) ── */}` and ending just before the comment `{/* hairline */}`. This includes the `` and the `{platform === "other" && (...)}` notice. + +Replace it with: + +```tsx + {/* ── hero button + launched chip ── */} +
+ + + {copied && } + +
+ + {/* ── section divider ── */} + + + {/* ── stepper cards ── */} + + {WIN_STEPS.map((step, i) => ( +
+ + {i < WIN_STEPS.length - 1 && ( + + )} +
+ ))} +
+ + {/* ── non-windows notice ── */} + {platform === "other" && ( + + +

+ Windows installer shown. macOS & Linux builds are rolling + out — your clipboard will still receive the signed command. +

+
+ )} +``` + +- [ ] **Step 2: Remove the now-unused `revealedIndex` state and cascade effect** + +Locate line 149: +```tsx + const [revealedIndex, setRevealedIndex] = useState(-1); +``` +Delete this line entirely. + +Locate lines 178-184: +```tsx + useEffect(() => { + if (!open) { + setCopied(false); + setCopying(false); + setRevealedIndex(-1); + } + }, [open]); +``` +Change to (removing the `setRevealedIndex(-1)` line): +```tsx + useEffect(() => { + if (!open) { + setCopied(false); + setCopying(false); + } + }, [open]); +``` + +Locate the sequential cascade useEffect (lines 186-193): +```tsx + // sequential cascade after copy + useEffect(() => { + if (!copied) return; + const timers = [0, 180, 360].map((delay, i) => + window.setTimeout(() => setRevealedIndex(i), delay), + ); + return () => timers.forEach((t) => window.clearTimeout(t)); + }, [copied]); +``` +Delete this entire block. + +- [ ] **Step 3: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "refactor(cta): swap TryFreeCard+StepRow for HeroButton+StepCard layout" +``` + +--- + +## Task 12: Remove unused components (TryFreeCard, StepRow, ConfettiBurst, ProgressRing, KeyCap) + +**Files:** +- Modify: `src/components/ui/CtaModal.tsx` — delete unused sub-component definitions + +- [ ] **Step 1: Delete TryFreeCard function** + +Delete the entire `TryFreeCard` function block (comment header `/* ═… TRY FREE CARD … */` through closing brace). It's no longer called. + +- [ ] **Step 2: Delete StepRow function** + +Delete the entire `StepRow` function block (comment header `/* ═… STEP ROW … */` through closing brace). + +- [ ] **Step 3: Delete KeyCap function** + +Delete the entire `KeyCap` function block (comment header `/* ═… KEYCAP … */` through closing brace). The new `StepCard` uses inline `` markup, no KeyCap needed. + +- [ ] **Step 4: Delete ConfettiBurst function and BURST_PARTICLES constant** + +Delete the `BURST_PARTICLES` constant and the `ConfettiBurst` function block. + +- [ ] **Step 5: Delete ProgressRing function** + +Delete the entire `ProgressRing` function block — not used by the simplified HeroButton. + +- [ ] **Step 6: Clean unused imports** + +In the `lucide-react` import at the top (around line 14-24), remove these now-unused icons: `Sparkles` is still used (non-windows notice), `Globe`, `Key`, `Lock` still used in ORBIT_ICONS. Keep them. But if `Sparkles` is imported twice or anywhere else is unused, clean up. Do NOT remove `CheckCircle2` (used by LaunchedChip) or `ArrowRight` (used by HeroButton). + +Verify the import block still reads valid TypeScript after edits. + +- [ ] **Step 7: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: PASS. If any icon is reported unused by eslint, remove it from the import list. + +- [ ] **Step 8: Commit** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "refactor(cta): remove unused TryFreeCard, StepRow, KeyCap, ConfettiBurst, ProgressRing" +``` + +--- + +## Task 13: Full build verification + +**Files:** +- No source changes in this task — verification only + +- [ ] **Step 1: Run full check** + +Run: `npm run check` +Expected: all three (lint + typecheck + build) pass clean. 35 routes build without warnings/errors. + +- [ ] **Step 2: If any step fails — fix inline, rerun** + +Common failures and fixes: +- **`react-hooks/set-state-in-effect`:** ensure no `setState` appears directly in an effect body (only inside timers / event callbacks). +- **Unused import:** delete the icon from the `lucide-react` import list. +- **Type error in HeroButton `disabled` prop:** `MagneticButton` may not accept `disabled`. If so, replace `disabled={disabled}` with `onClick={disabled ? undefined : handleClick}` and move the `disabled` styling to classes only. +- **motion variants type mismatch:** ensure all `motion.*` components with `variants={}` use compatible `Variants` objects. + +After any fix, rerun `npm run check` until clean. + +- [ ] **Step 3: Commit final polish (if fixes were needed)** + +```bash +git add src/components/ui/CtaModal.tsx +git commit -m "fix(cta): resolve build issues in hero-ribbon refactor" +``` + +(Skip if no fixes needed.) + +--- + +## Task 14: Final handoff to user + +**Files:** +- No changes — just messaging + +- [ ] **Step 1: Report status to user** + +Tell the user: +> 🟢 CtaModal перестроен по спеке. Typecheck/lint/build чисты. Dev-server уже крутится на :3000 — открывай и смотри. Логику не трогал (`copyAndLaunch`, `useCtaModal`, escape, scroll lock, AbortError, fallback — дословно те же). Если визуально что-то не так — скажи какую секцию правим. + +Do NOT dispatch Playwright / frontend-qa automatically — per project rule the user triggers visual QA themselves. + +--- + +## Self-Review + +### Spec coverage check + +| Spec section | Implemented in task(s) | +|--------------|----------------------| +| Pure emerald palette (no violet) | Task 1 (mesh orb) | +| VPNOrbit demoted + acknowledgment flash | Task 2 | +| VPNOrbit pulse rings retimed to 4.8s + phase offset | Task 2 | +| VPNOrbit radial bleed | Task 2 | +| AuroraBacklight inside shell | Tasks 3-4 | +| HeroButton label "Try Free" + magnetic + glow | Task 5 | +| HeroButton post-click disabled, stays in place | Task 5 (disabled/opacity-35/pointer-events-none) | +| LaunchedChip above button, slide-in | Task 6 + Task 11 mount | +| SectionDivider "Затем в Проводнике:" | Task 7 + Task 11 mount | +| Vertical StepCard × 3 with dim/active states | Task 8 + Task 11 mount | +| StepperConnector vertical segments | Task 9 + Task 11 mount | +| Cascade via motion variants staggerChildren | Task 10 (timing) + Task 11 (no revealedIndex) | +| Remove `revealedIndex` state + cascade effect | Task 11 Step 2 | +| Remove unused TryFreeCard/StepRow/ConfettiBurst/ProgressRing/KeyCap | Task 12 | +| Logic preserved verbatim | Not changed by any task (copyAndLaunch, useCtaModal, escape, scroll lock, AbortError, fallback, focus management all untouched) | +| Reduced motion handling | Preserved via existing `reduceMotion` prop plumbing in Tasks 2, 3, 5 | +| Mobile void` — matches call site. +- `StepCard` props: `step: StepSpec, index: number, active: boolean` — `StepSpec` exists at line 31; `active` driven by `copied`. +- `StepperConnector` props: `active: boolean` — same source. +- `VPNOrbit` props: `reduceMotion: boolean, acknowledged: boolean` — call site passes `!!reduceMotion` and `copied`. +- `AuroraBacklight` props: `reduceMotion: boolean` — call site passes `!!reduceMotion`. +- `LaunchedChip` props: none. +- `SectionDivider` props: none. +- Imports: `MagneticButton`, `BorderTrail`, `CheckCircle2`, `ArrowRight`, `ShieldCheck`, `Wifi`, `Sparkles`, `Globe`, `Key`, `Lock`, `X` all still referenced after cleanup. + +### Tech-stack notes + +- Project has no test runner by design. Verification = typecheck + lint + build. TDD loop replaced with "write → verify → commit" per task. +- Pre-commit hook enforces conventional commit format. All task commits use `feat/refactor/fix/style/chore(cta): ...`. +- Project lesson `react-hooks/set-state-in-effect`: no `setState` directly in `useEffect` body in any new code. All state changes happen in event handlers (onClick) or sync-setup paths, not effect bodies. + +--- + +## Execution Handoff + +Plan saved to `docs/superpowers/plans/2026-04-24-ctamodal-hero-ribbon.md`. + +Два варианта запуска: + +**1. Subagent-Driven (рекомендую)** — свежий subagent на каждую задачу, я ревьюю между тасками, быстрые итерации. Плюс: изоляция контекста. Минус: чуть дольше из-за переключений. + +**2. Inline Execution** — выполняю задачи в этой же сессии через executing-plans, батчами с чекпоинтами. Плюс: один поток, без переключений. Минус: засоряется текущий контекст. + +Какой вариант? diff --git a/docs/superpowers/specs/2026-04-24-ctamodal-hero-ribbon-design.md b/docs/superpowers/specs/2026-04-24-ctamodal-hero-ribbon-design.md new file mode 100644 index 00000000..a401ee86 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-ctamodal-hero-ribbon-design.md @@ -0,0 +1,321 @@ +# CtaModal — Hero + Vertical Stepper redesign (Aurora Emerald) + +**Date:** 2026-04-24 +**Component:** `src/components/ui/CtaModal.tsx` +**Scope:** Windows-only install flow; visual + interaction redesign. Logic frozen. + +## Goal + +Replace the current "4 equal cards + Try Free first" layout with a clear **single-hero-action + instruction-stepper** hierarchy. The user must understand at a glance: (1) press the big button, (2) then do these 3 keystrokes in the Explorer dialog that opens. All 4 things are visible from the start; the button dominates; the 3 keystrokes read as a cheat-sheet, not as buttons. + +Preserve `useCtaModal`, `copyAndLaunch`, escape-to-close, body scroll lock, focus management, AbortError handling, `showOpenFilePicker` → `` fallback. + +## Non-goals + +- macOS / Linux / mobile install flows. +- `CtaModalContext` API, trigger call sites, CSS variables. +- New dependencies. motion@12.38, lucide, shadcn (base-nova) already in stack. +- Light mode. +- Test infrastructure. +- Secondary accent colors. **Pure emerald on dark. Violet is banned in this surface.** Depth comes from luminance layers, blur, and frost — not a second hue. + +## Design pillars (grounded in research) + +1. **ProtonVPN's own onboarding** uses a 2-col "decorative left + actionable right" modal. We preserve that lineage. +2. **Premium monochrome accent** (Linear / Vercel / Apple Pro) — hierarchy from tonal elevation, not a second color. Each surface tint lifts slightly toward emerald as it gains importance. +3. **Keyboard shortcuts as discrete chips, never prose** (VS Code, Raycast). Spatial-visual recognition beats reading. +4. **Connector progression via opacity/color shift, not animated stroke-dashoffset.** Production premium keeps it simple; the luxury is in timing, not in SVG theatrics. +5. **One master breathing rhythm** for the modal: 4.8s master beat. Orbit pulse-rings 4.8s. Hero glow 2.4s (phase-aligned, half-beat). Stepper cascade 150ms stagger. Aurora atmosphere 60s slow rotate. Modal feels like one organism. + +## Layout (900px modal, 2-column) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [status-dot] AuraVPN Free (HyperScramble) [X] │ ← header 48px +├──────────────────┬───────────────────────────────────────────┤ +│ │ Install AuraVPN on Windows │ +│ │ One secure tunnel. One click. Three keys. │ +│ ░░ VPNOrbit ░░ │ │ +│ (280px wide) │ AuraVPN-Setup-1.2.0.exe · 48 MB · SHA ✓ │ +│ │ │ +│ opacity 0.55 │ ╔═══════════════════════════════════════╗ │ +│ saturate 0.7 │ ║ GET AURAVPN → ║ │ ← HERO +│ │ ╚═══════════════════════════════════════╝ │ +│ pulse rings │ │ +│ 4.8s master │ │ Затем в Проводнике: │ ← divider +│ │ │ +│ on-click: │ ① Open address bar [Ctrl] [L] │ ← card +│ brief brighten │ │ Focus the path input at the top │ +│ (0.55→0.9, │ │· │ +│ pulse 1→1.3, │ ② Paste command [Ctrl] [V] │ +│ 600ms) │ │ Clipboard already filled for you │ +│ │ │· │ +│ │ ③ Run installer [Enter] │ +│ │ │ Windows installer launches │ +│ │ │ +│ │ Encrypted · WireGuard · SHA-256 [Cancel] │ ← footer 56px +└──────────────────┴───────────────────────────────────────────┘ +``` + +Below `md:` breakpoint the left aside is hidden; right column goes full width. Cards restructure to stack (number + keycap top row, label + desc bottom row) if width < 400px. + +## Components (all inside `CtaModal.tsx`) + +### Preserved +- `MeshOrbs` — behind modal, unchanged (emerald + black radial bleeds). +- `NoiseLayer` — SVG turbulence film grain, unchanged. +- `StatusDot` — pulsing emerald dot in header. +- `HyperScramble` — A-Z glitch badge for variant name, unchanged. +- `ModalShell` — widened max-w to 900px (already in place). + +### Reworked +- **`VPNOrbit`** (left aside, 280px): + - Same structure: central ShieldCheck, 3 orbiting icons (Lock / Globe / Key), dashed orbit ring, 2 pulse rings, bottom meta "secured tunnel · wireguard · sha-256". + - Demoted visual: `opacity-[0.55]`, `filter: saturate(0.7)`. Reads as atmosphere, not focal element. + - Pulse rings re-timed to **4.8s** master beat. + - Adds state prop `acknowledged: boolean`. When true (copyAndLaunch succeeds): 600ms brightness flash — opacity eases to 0.9, pulse ring scale 1→1.3, then returns to calm. Purely acknowledgment; not a progress indicator. + +### New / replaced +- **`HeroButton`** (replaces `TryFreeCard`): + - Full-width pill inside right col content, 64px tall. Label: **"Try Free"** (idle) → check-chip (post-click). + - Wrapped in existing `MagneticButton` (strength 0.25, radius 120px). + - Double border: outer `border-emerald-500/70`, inner `ring-1 ring-inset ring-emerald-400/30`. + - Background: `bg-gradient-to-br from-emerald-500/20 via-emerald-600/15 to-transparent`. + - Glow: `shadow-[0_0_40px_-8px_oklch(0.78_0.17_156/0.55)]`, pulses 2.4s ease-in-out (phase-half of orbit master beat). + - Conic-gradient shimmer on inner ring (shiny-button pattern), 2.4s rotation. + - On hover: shadow grows to `0_0_60px_-8px`, scale 1.01. + - On click (copyAndLaunch success): fades to `opacity-35`, `pointer-events-none`, `cursor-not-allowed`. **Stays in place.** A separate `LaunchedChip` slides in above it. + +- **`LaunchedChip`** (new): + - Small pill above HeroButton, appears after successful copyAndLaunch. + - Content: ` Installer launched`. + - Enter: `opacity 0 → 1`, `x: 12 → 0`, 220ms. Persists until modal close. + +- **`SectionDivider`** (new): + - Between hero and stepper. 40px vertical space. + - Content: `border-l-2 border-emerald-400/50` left bar, label **"Затем в Проводнике:"** in zinc-300 14px medium. + - Establishes "step 1 done → now do these" narrative. + +- **`StepperRail`** (replaces horizontal ribbon / old StepRow stack): + - Vertical list of 3 `StepCard`s, 12px gap. + - Between cards: 2px vertical segment line, absolute positioned in the 12px gap, `bg-white/5` dim / `bg-emerald-400/40` active. Height 16px so it visually bridges card borders. + - Segments activate in cascade post-copyAndLaunch with 150ms stagger. + +- **`StepCard`** (replaces old `StepRow`): + - Height 72px, full width, rounded-[16px], border. + - Internal grid: `[28px_1fr_auto]` columns, 16px gap, 14px padding. + - **Number chip** (28px circle, composed `` with `rounded-full` + inline digit "1" / "2" / "3"): border-emerald-400/40 dim with zinc-400 digit / solid emerald-400 bg with black-900 digit active. Avoid Unicode ① ② ③ (renders inconsistently across fonts). + - **Label column**: 14px zinc-200 title ("Open address bar") + 12px zinc-500 desc ("Focus the path input at the top"). + - **Keycap column**: shadcn `Kbd` / `KbdGroup` — JetBrains Mono 13px. Dim zinc-400, active emerald-200. + - States: + - **Dim** (default): `bg-white/[0.01]`, `border-white/[0.05]`, text/chip/keycap muted. + - **Active** (post-copyAndLaunch, cascade): `bg-emerald-400/[0.05]`, `border-emerald-400/30`, chip solid, label zinc-50, keycap emerald-200. + - Transition: 220ms ease-out on bg/border/color. No layout shift. + +- **`AuroraBacklight`** (new, below modal shell content, above mesh orbs): + - Repeating-linear-gradient `emerald-900/20 → transparent → emerald-950/25 → transparent`. + - `blur-[80px]`, `opacity-30`, slow rotate 60s linear infinite. + - Delivers the Aurora Glass promise without introducing a second color. + +### Removed +- `TryFreeCard` — absorbed into HeroButton. +- The 01 numbering on what was step-01 (hero) — hero has no number, it's THE action. Stepper cards are ① ② ③ (not 02 03 04 anymore — they're the only numbered sequence now). +- Old horizontal `StepRow` ribbon semantics. + +## Motion choreography + +### Master rhythm +| Clock | Bound to | +|-------|----------| +| 4.8s | VPNOrbit pulse rings | +| 2.4s | HeroButton glow pulse, inner conic shimmer | +| 60s | AuroraBacklight rotation | +| 20s | VPNOrbit icon orbit (unchanged) | +| 150ms | Stepper cascade stagger | + +### Entrance (modal open) +- Backdrop fade 260ms. +- Modal shell spring `damping:24 stiffness:280`, `y:24 → 0`, `scale:0.96 → 1`, 80ms delay. +- Right column children cascade: header 240ms · title 320ms · meta 420ms · HeroButton 520ms · SectionDivider 640ms · StepCards stagger(0.08) starting 720ms · footer 980ms. All fade+rise-8. +- VPNOrbit: fade-in 600ms + scale 0.9→1, no delay. +- AuroraBacklight: fade 800ms, rotation starts immediately. + +### Post-click (copyAndLaunch resolves successfully) +| t (ms) | Element | Change | +|--------|---------|--------| +| 0 | HeroButton | opacity → 0.35, disabled | +| 0 | VPNOrbit | brighten flash: opacity 0.55→0.9, pulse ring scale 1→1.3 | +| 0 | LaunchedChip | mount, slide-in from x:12 | +| 150 | StepCard ① | state → active (bg/border/text transition 220ms) | +| 150 | Segment ①→② | bg → emerald | +| 300 | StepCard ② + segment ②→③ | active | +| 450 | StepCard ③ | active | +| 600 | VPNOrbit | settle back to opacity 0.55, pulse scale 1 | + +No auto-reset. State persists until modal close. Reopening modal = fresh idle state. + +### Reduced motion +`useReducedMotion()` gate disables: orbit icon rotation, aurora rotation, pulse rings, conic shimmer, magnetic pull, cascade stagger (instant switch to active). Entrance becomes 120ms crossfade. LaunchedChip switches instantly. + +## Typography & palette + +| Role | Value | +|------|-------| +| Surface base | `oklch(0.12 0.005 260)` at 70% alpha + backdrop-blur | +| Emerald primary | `oklch(0.78 0.17 156)` (= emerald-400) | +| Emerald deep | `oklch(0.66 0.17 156)` (= emerald-500) | +| Text primary | `zinc-50` | +| Text secondary | `zinc-300` / `zinc-400` | +| Text muted / mono | `zinc-500` | +| Border dim | `white/[0.05]` | +| Border active | `emerald-400/30` | +| Glow shadow | `oklch(0.78 0.17 156 / 0.55)` | + +- Inter: title 28px / semibold / tracking -0.03em, body 14px, small 12-13px. +- JetBrains Mono: keycaps, file meta, SHA hash. +- No Syncopate, no second sans, no second accent. + +## shadcn primitives to use (research-confirmed available) + +- `Button` — base for HeroButton (CVA variant extended in-file). +- `Kbd` + `KbdGroup` — stepper keycaps. +- `Badge` — (optional) for HyperScramble variant pill if we want to swap current custom. +- `Separator` — (optional) footer divider. + +Must compose manually (no primitive): +- Glow + conic shimmer on button. +- Magnetic hover (already have `MagneticButton`). +- Filling connector segments (plain div bg transitions, no SVG). +- AuroraBacklight. +- VPNOrbit (already custom). +- Cascade timing (motion variants + staggerChildren). + +## Logic preserved (verbatim, do not touch) + +- `useCtaModal` hook. +- `copyAndLaunch` — clipboard write + `showOpenFilePicker({startIn:"downloads"})` with AbortError / SecurityError / NotAllowedError early return + `` fallback via `fileInputRef.current?.click()`. +- Escape listener, body scroll lock, focus management on open. +- `PLATFORMS.windows` meta + `WIN_STEPS`. +- Variant config (free / plus / business) — badge label + copy only. + +## Logic removed / clean up + +- `TryFreeCard` component definition (absorbed). +- `revealedIndex` state (replaced by simple `copied` boolean cascading via motion variants). +- Old horizontal ribbon styles. +- Any leftover `useDemoSequence` / auto-demo hooks (already removed in prior iteration — confirm gone). + +## Acceptance criteria + +1. `npm run typecheck` clean. +2. `npm run lint` clean (0 errors, 0 warnings; no `react-hooks/set-state-in-effect`). +3. `npm run build` clean (35 routes). +4. Visual verification (user-triggered, not auto): + - Modal opens, staggered reveal plays, all 4 elements (HeroButton + 3 StepCards) visible from start. + - HeroButton dominates; StepCards are clearly dim. + - Click HeroButton → file picker opens exactly once (no double). + - LaunchedChip appears above button, button fades to disabled state **but stays in place**. + - StepCards cascade in ①→②→③ with visible 150ms stagger and connector segments filling. + - VPNOrbit briefly brightens at t=0 and settles. + - `prefers-reduced-motion: reduce` disables all orchestration. +5. Viewport < md (768px): left aside hidden, content full-width, StepCards usable. +6. A11y: HeroButton has `aria-busy="true"` while copying, StepCards have `aria-label` including label + shortcut (e.g., "Step 1: Open address bar, Ctrl+L"). + +## Out of scope + +- Non-Windows flows. +- Haptics, sound. +- SSR-safe `detectPlatform` changes. +- Theming / light mode. +- Analytics events. + +## Appendix: Absolute-grade specifications + +Four specialist passes locked every value. Implementation pulls from this section. + +### A1. Hero "Try Free" button (absolute) + +- Height `64px`, border-radius `120px` (full pill), `px-8 py-4`. +- Inner layout: `flex items-center justify-center gap-2`. Label Inter 14px `font-semibold tracking-tight` "Try Free". Trailing ``; on group-hover translate-x 0.5. +- Border stack: outer `border-2 border-emerald-500/70` + inner `ring-2 ring-inset ring-emerald-400/30`. +- Conic shimmer on ring (2.4s linear): + - `conic-gradient(from var(--angle) at 50% 50%, emerald-400/0 0%, emerald-300/40 20%, emerald-400/30 50%, emerald-300/15 80%, emerald-400/0 100%)` + - Mask `radial-gradient(circle at 50% 50%, transparent 42%, black 48%, black 52%, transparent 58%)`. + - CSS var `--angle` animated 0→360deg. `background-clip: border-box`. +- Idle shadow stack: + - `0 0 0 1px oklch(0.66 0.17 156 / 0.4)` + - `0 10px 32px -8px oklch(0.66 0.17 156 / 0.6)` — breathes blur-radius 32→40 @ 2.4s ease-in-out (phase 0, half-beat of 4.8s master). + - `inset 0 1px 0 oklch(1 0 0 / 0.3)`. +- Background fill: `bg-gradient-to-b from-emerald-500/20 via-emerald-600/15 to-emerald-500/[0.05]`. +- Hover 200ms cubic-bezier(0.16, 1, 0.3, 1): gradient stops lift one oklch-lightness tick (`from-emerald-400 via-emerald-400/90`), shadow `0 14px 44px -6px emerald-400/70`, `scale-[1.01]`. MagneticButton strength 0.30, radius 120px, spring stiffness 150 damping 15. +- Press 80ms ease-out: `scale-[0.98]`, shadow collapses to `0 2px 8px -2px emerald-500/40`. +- Post-click disabled: `opacity-35`, `pointer-events-none`, `cursor-not-allowed`. Shimmer CSS animation paused. Shadow freezes at `0 0 0 1px emerald-500/40, 0 10px 32px -8px emerald/0.3` (pulse stops). Button stays in place. +- Focus-visible: `outline-none ring-2 ring-emerald-400/70 ring-offset-2 ring-offset-[oklch(0.12_0.005_260)]`. +- Entrance variant: `{opacity:0, y:8} → {opacity:1, y:0}`, 400ms ease `[0.16,1,0.3,1]`, delay ~520ms after shell. +- Reduced motion: shimmer disabled, pulse disabled (shadow locked at mid value `0 8px 24px -6px emerald-400/40`), magnetic off, entrance collapses to 120ms opacity. +- a11y: `aria-busy` while `copying`. LaunchedChip (separate sibling) uses `aria-live="polite"`. + +### A2. Vertical 3-card stepper (absolute) + +- Card: `h-[68px] rounded-2xl px-4 py-3 grid grid-cols-[28px_1fr_auto] items-center gap-4`. +- Dim state: `border border-white/[0.05] bg-white/[0.01] opacity-[0.55]`. +- Active state: `border border-emerald-400/30 bg-emerald-400/[0.06] opacity-100`. +- Transition on border/bg/text/opacity: `220ms ease-out`. +- Hover on dim (cursor-default, NOT pointer): border `white/[0.08]`, bg `white/[0.02]`, 160ms. +- **Number chip** (28×28): `rounded-lg` (not circle — matches premium rectangular language), centered "01" / "02" / "03" in JetBrains Mono 11px, tracking-wider. Dim: `border border-white/[0.05] text-zinc-500`. Active: `border border-emerald-400/30 bg-emerald-400/15 text-emerald-200 shadow-[0_0_12px_-2px_rgb(16_185_129/0.5)]`. +- **Label column**: title `text-[13.5px] font-medium text-zinc-100 leading-tight`. Description `text-[12px] text-zinc-500 leading-snug mt-1`. +- **Keycap column**: shadcn `KbdGroup` with individual `Kbd` per key, `+` separator between them. `Kbd` base: `font-mono text-[11px] px-2 py-[3px] rounded-md border border-white/[0.08] bg-white/[0.02] text-zinc-400`. Active-card Kbd: `border-emerald-400/30 bg-emerald-400/10 text-emerald-200`. +- **Connector segments** between cards (between card 1↔2 and 2↔3): `absolute left-[28px]` (aligned to chip column center), `w-[2px] h-4 -translate-y-2` positioned in 12px card gap. Dim `bg-white/[0.05]`, active `bg-emerald-400/40`. Transition 220ms. +- **Cascade timing** (triggered at `copied === true`): + - Parent variants: `delayChildren: 0.15s` (initial), `staggerChildren: 0.15s`. + - Each card activates: border+bg+chip+keycap+text all shift together at 220ms ease-out. + - Concurrent **glow pulse** on activation: box-shadow `0 0 16px -8px emerald/0` → `0 0 24px -4px emerald/0.5` → `0 0 16px -8px emerald/0` over 600ms ease-out (one-shot). + - Connector segment fills simultaneously with the card entering active state. +- Reduced motion: instant state switch, no pulse, no stagger. All three active at once when `copied` flips. +- Cards NOT focusable (`tabindex` default, no role="button"). Screen-reader semantics via `
    /
  1. ` with `aria-label="Step 1: Open address bar, Ctrl+L"`. +- Mobile ` + `secured tunnel` in JetBrains Mono `text-[9px] uppercase tracking-[0.22em] text-emerald-300/75`. Bottom line `wireguard · sha-256` mono `text-[8px] tracking-[0.18em] text-zinc-600`. +- **Acknowledgment flash** (on `copied === true`, 600ms): container opacity `[0.55, 0.9, 0.55]` easeInOut. Pulse rings concurrent scale spike `1 → 1.15 → 1`. Shield plate shadow radius pump `40 → 52 → 40`. +- Reduced motion: all animations freeze at base state. No rotation, no scale, no pulse. Visibility retained. + +### A4. Atmospheric layer stack (absolute) + +- **Page backdrop**: `fixed inset-0 bg-black/85`. NO `backdrop-filter` here (budget reserved for shell). Opacity 0→1 over 260ms on open. +- **Mesh orbs** (2, emerald-only, inside overlay behind shell): + - Orb A: `absolute left-[15%] top-[12%] size-[420px] rounded-full bg-emerald-500/20 blur-[140px]`, drift 22s ease-in-out infinite `x: [0, 40, -20, 0]`, `y: [0, -28, 22, 0]`. + - Orb B: `absolute right-[10%] bottom-[10%] size-[380px] rounded-full bg-emerald-700/25 blur-[140px]`, drift 26s `x: [0, -30, 24, 0]`, `y: [0, 22, -18, 0]`. +- **Modal shell**: `max-w-[900px] rounded-[26px] bg-[oklch(0.12_0.005_260)]/70 backdrop-blur-[28px] border border-white/[0.08]`. + - Shadow stack: `0 50px 140px -20px rgba(0,0,0,0.9)`, `0 0 0 1px rgba(255,255,255,0.04)`, `inset 0 1px 0 rgba(255,255,255,0.06)`, `0 0 60px -20px oklch(0.5 0.20 143 / 0.25)` (static outer emerald bloom). +- **Aurora backlight** (inside shell, z-0, clipped to rounded-[26px]): + - `absolute inset-0 rounded-[26px] overflow-hidden pointer-events-none`. Inner child: conic-gradient `from var(--aurora-angle) at 50% 50%` with stops oklch(0.28 0.20 143) 0% / oklch(0.12 0.005 260 / 0) 33.33% / oklch(0.48 0.18 141) 66.66% / oklch(0.12 0.005 260 / 0) 100%, `filter: blur(80px)`, `opacity 0.3`, rotation 60s linear infinite. +- **Noise overlay** (inside shell, z-10, above aurora, below content): SVG turbulence `baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"` + `feColorMatrix saturate 0`, applied via `` full-size. `opacity-[0.035] mix-blend-overlay pointer-events-none`. +- **Content grid** z-20 (header / hero / stepper / footer / aside-orbit). +- **LaunchedChip** z-50 absolute above hero button, `rounded-full border border-emerald-400/40 bg-emerald-400/10 text-emerald-100 text-[12px] px-3 py-1 flex items-center gap-1.5` with `` + "Installer launched". Enter: `{opacity:0, x:12} → {opacity:1, x:0}`, 220ms ease-out. +- Entrance sequence: + - t=0 backdrop fade 260ms. + - t=0 orbs fade+scale 0.85→1 over 600-700ms. + - t=80 shell spring `{y:24→0, scale:0.96→1, damping:24, stiffness:280}`. + - t=120 aurora opacity 0→0.3 over 800ms. + - t=120 noise opacity 0→0.035 over 400ms. +- Reduced motion: orbs static at midpoint, aurora static at 0.3 no rotate, shell instant, noise immediate. +- Performance budget: 1 `backdrop-blur` (shell) + 3 plain `blur()` (orb A, orb B, aurora). No `will-change` outside 120ms shell entrance window. No filters on content layer. + +## Risks & mitigations + +- **Multiple blur layers on low-end GPUs** (AuroraBacklight + modal backdrop-blur + mesh orbs blur). Mitigation: AuroraBacklight uses plain `blur()` (non backdrop-filter); only modal shell uses backdrop-blur. +- **Conic shimmer perf on the button** — cheap CSS mask, GPU-composited. Mitigation: throttle to 2.4s, no repaints beyond transform. +- **Magnetic pull on primary CTA** conflicts with click timing on slow devices. Mitigation: disable magnetic when reduced motion or touch input. +- **Cascade visible through reduced motion** — verify by forcing `matchMedia('(prefers-reduced-motion: reduce)')` in devtools. +- **Button stays visible but disabled post-click** — new behavior; verify user doesn't attempt second click. Visual hint: `cursor-not-allowed` + 35% opacity should communicate. diff --git a/src/app/(site)/blog/page.tsx b/src/app/(site)/blog/page.tsx new file mode 100644 index 00000000..5b12e5d2 --- /dev/null +++ b/src/app/(site)/blog/page.tsx @@ -0,0 +1,91 @@ +import { DocCallout, DocInlineLink } from "@/components/proton/DocBlocks"; +import { docMetadata } from "@/lib/page-meta"; +import { BLOG_POSTS } from "@/lib/blog-posts"; +import { ROUTES } from "@/lib/site-routes"; +import { syne } from "@/lib/proton-fonts"; +import { cn } from "@/lib/utils"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export const metadata = docMetadata( + "Blog", + "/blog", + "Security guides, audits, and product deep dives.", +); + +export default function BlogIndexPage() { + const entries = Object.entries(BLOG_POSTS); + return ( +
    +
    + + + +

    + Blog +

    +

    + Longer articles live on their own URLs inside this demo. Topics cover + audits, open source, browser extensions, mobile privacy, and kill + switch behaviour — all scoped to the VPN product line. +

    + + +

    + Replace or extend posts in{" "} + + src/lib/blog-posts.ts + + . For MDX later, swap the dynamic route to read from the filesystem. +

    +
    + +
      + {entries.map(([slug, post]) => ( +
    • + +

      + {post.title} +

      +

      + {post.excerpt} +

      + + Read article → + + +
    • + ))} +
    + +

    + More to explore:{" "} + Support + {" · "} + Features +

    +
    + ); +} diff --git a/src/app/(site)/business/page.tsx b/src/app/(site)/business/page.tsx new file mode 100644 index 00000000..8c1e750c --- /dev/null +++ b/src/app/(site)/business/page.tsx @@ -0,0 +1,72 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { + Banknote, + GitBranch, + Headphones, + Server, + UserCheck, +} from "lucide-react"; + +export const metadata = docMetadata( + "Business VPN", + "/business", + "VPN for teams: deployment, billing, and support.", +); + +export default function BusinessPage() { + return ( + + Enterprise features + Common requirements + + Rollout pattern +

    + Pilot on a single team, gather latency feedback, then expand with MDM + packages for macOS and Windows and managed app configs for mobile. Use + the{" "} + Download page to + deep-link installers per platform. +

    + +

    + + Business contact + {" "} + — demo form placeholder. For quotes, attach expected seat count and + regions. +

    +
    +
    + ); +} diff --git a/src/app/(site)/contact/page.tsx b/src/app/(site)/contact/page.tsx new file mode 100644 index 00000000..d96c3b44 --- /dev/null +++ b/src/app/(site)/contact/page.tsx @@ -0,0 +1,62 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { Bug, ExternalLink, Newspaper, Wrench } from "lucide-react"; + +export const metadata = docMetadata( + "Contact", + "/contact", + "Press, partnerships, and general inquiries.", +); + +export default function ContactPage() { + return ( + + Routing guide + Before you write + + Faster self-serve links +

    + Help and support + {" · "} + FAQ + {" · "} + Partners +

    + +

    + Embed HubSpot, Formspree, or a simple{" "} + + mailto: + {" "} + button on this route when you deploy. +

    +
    +
    + ); +} diff --git a/src/app/(site)/download/page.tsx b/src/app/(site)/download/page.tsx new file mode 100644 index 00000000..99f1649a --- /dev/null +++ b/src/app/(site)/download/page.tsx @@ -0,0 +1,237 @@ +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { syne } from "@/lib/proton-fonts"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import type { LucideIcon } from "lucide-react"; +import { ArrowLeft, Flame, Globe, Laptop, Monitor, Smartphone, Tv } from "lucide-react"; + +const PRIMARY_PLATFORMS: { + id: string; + label: string; + Icon: LucideIcon; + blurb: string; +}[] = [ + { + id: "windows", + label: "Windows", + Icon: Monitor, + blurb: + "Full GUI for Windows 10 and 11 with tray controls, split tunneling, and kill switch. Ideal for laptops moving between office, home, and public Wi‑Fi.", + }, + { + id: "macos", + label: "macOS", + Icon: Laptop, + blurb: + "Universal build for Apple silicon and Intel. Integrates with menu bar, supports per-app exclusions, and follows macOS network extensions guidelines.", + }, + { + id: "android", + label: "Android", + Icon: Smartphone, + blurb: + "Google Play build plus optional APK for sideloading. Supports always-on VPN and per-app split tunneling on recent Android versions.", + }, + { + id: "ios", + label: "iPhone / iPad", + Icon: Smartphone, + blurb: + "Distributed via the App Store with on-demand rules compatible with iOS network extensions. Use the Shortcuts widget for quick connect.", + }, +]; + +const SECONDARY_PLATFORMS: { + id: string; + label: string; + Icon: LucideIcon; + blurb: string; +}[] = [ + { + id: "linux", + label: "Linux", + Icon: Laptop, + blurb: + "CLI for servers and headless boxes; graphical builds exist for major desktops. Package formats vary by distro — .deb, .rpm, or community packages.", + }, + { + id: "chrome", + label: "Chrome", + Icon: Globe, + blurb: + "Lightweight extension for Chromium — proxies browser tabs only. Pair with the desktop app for system-wide coverage.", + }, + { + id: "firefox", + label: "Firefox", + Icon: Globe, + blurb: + "Signed WebExtension with the same scope model as Chrome: web traffic inside Firefox uses the tunnel; other apps do not.", + }, + { + id: "chromebook", + label: "Chromebook", + Icon: Laptop, + blurb: + "Many Chromebooks run Android VPN clients; Linux (Crostini) has separate networking — confirm which mode matches your policy.", + }, + { + id: "apple-tv", + label: "Apple TV", + Icon: Tv, + blurb: + "tvOS clients focus on streaming reliability. Use Ethernet where possible for 4K throughput through the tunnel.", + }, + { + id: "android-tv", + label: "Android TV", + Icon: Tv, + blurb: + "Navigate with the D-pad, pin the app to the launcher, and set automatic connect for guest networks.", + }, + { + id: "fire-tv", + label: "Fire TV", + Icon: Flame, + blurb: + "Amazon Fire OS build from the Appstore. Side-loaded APKs skip automatic updates — prefer the store listing when available.", + }, +]; + +export const metadata = docMetadata( + "Download", + "/download", + "Download VPN apps — demo hub with in-app links.", +); + +export default function DownloadPage() { + return ( +
    +
    + + + +

    + Download AuraVPN +

    +
    +

    + Pick your platform below. URLs use in-app anchors so the mega menu + can deep-link ( + + /download#windows + + ,{" "} + + #macos + + , etc.). +

    +

    + After install, open the app to get your anonymous token. If you need + feature comparisons first, open{" "} + + Features + {" "} + or{" "} + + Pricing + + . +

    +
    + + {/* Primary platforms */} +
    + {PRIMARY_PLATFORMS.map((p) => ( +
    +
    +
    + +
    +

    + {p.label} +

    +
    +

    + {p.blurb} +

    +

    + Production builds would live here. For now, return to{" "} + + Home + {" "} + or see{" "} + + Pricing + + . +

    +
    + ))} +
    + + {/* Divider */} +
    +
    + + More platforms + +
    +
    + + {/* Secondary platforms */} +
    + {SECONDARY_PLATFORMS.map((p) => ( +
    +
    +
    + +
    +

    + {p.label} +

    +
    +

    + {p.blurb} +

    +
    + ))} +
    +
    + ); +} diff --git a/src/app/(site)/free-vpn/page.tsx b/src/app/(site)/free-vpn/page.tsx new file mode 100644 index 00000000..0b6a6f5f --- /dev/null +++ b/src/app/(site)/free-vpn/page.tsx @@ -0,0 +1,62 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { Gauge, Monitor, Sparkles, Wifi } from "lucide-react"; + +export const metadata = docMetadata( + "Free VPN", + "/free-vpn", + "Free tier: what you get and when to upgrade.", +); + +export default function FreeVpnPage() { + return ( + + Free tier + What the free tier is good for + + Typical limits (conceptual) +

    + Free plans usually offer fewer simultaneous countries, moderate speeds, + and a single active connection. Streaming-optimized locations, highest + speeds, and advanced routing often require{" "} + VPN Plus. +

    + +

    + Open the{" "} + + pricing block on the home page + {" "} + for side-by-side cards, or visit the dedicated{" "} + Pricing page. +

    +
    +
    + ); +} diff --git a/src/app/(site)/netflix-vpn/page.tsx b/src/app/(site)/netflix-vpn/page.tsx new file mode 100644 index 00000000..388af336 --- /dev/null +++ b/src/app/(site)/netflix-vpn/page.tsx @@ -0,0 +1,54 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { CreditCard, Info, Tv, Zap } from "lucide-react"; + +export const metadata = docMetadata( + "Netflix VPN", + "/netflix-vpn", + "VPN use with Netflix — technical and policy context.", +); + +export default function NetflixVpnPage() { + return ( + + Before you connect + What users should know + + +

    + For general streaming guidance, see{" "} + + VPN for streaming + + . +

    +
    +
    + ); +} diff --git a/src/app/(site)/open-source/page.tsx b/src/app/(site)/open-source/page.tsx new file mode 100644 index 00000000..9b45dfcd --- /dev/null +++ b/src/app/(site)/open-source/page.tsx @@ -0,0 +1,52 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { Code2, GitBranch, Users } from "lucide-react"; + +export const metadata = docMetadata( + "Open source", + "/open-source", + "Public code, audits, and community review.", +); + +export default function OpenSourcePage() { + return ( + + Why it matters + Why it matters for VPN software + + +

    + + Blog: Open source VPN apps + +

    +
    +
    + ); +} diff --git a/src/app/(site)/partners/page.tsx b/src/app/(site)/partners/page.tsx new file mode 100644 index 00000000..86125626 --- /dev/null +++ b/src/app/(site)/partners/page.tsx @@ -0,0 +1,52 @@ +import { + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { Banknote, Handshake, Plug, Scale } from "lucide-react"; + +export const metadata = docMetadata( + "Partners", + "/partners", + "Affiliates, integrations, and co-marketing.", +); + +export default function PartnersPage() { + return ( + + Programme terms + Affiliate programme basics + + Technology partners +

    + Router vendors, MDM platforms, and privacy education nonprofits may + integrate deep links to{" "} + Download or SSO + guides. For commercial integration requests, start from{" "} + Contact. +

    +
    + ); +} diff --git a/src/app/(site)/secure-core-vpn/page.tsx b/src/app/(site)/secure-core-vpn/page.tsx new file mode 100644 index 00000000..e06f369e --- /dev/null +++ b/src/app/(site)/secure-core-vpn/page.tsx @@ -0,0 +1,52 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocH3, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { Gavel, Lock, Scale, Users } from "lucide-react"; + +export const metadata = docMetadata( + "Secure Core", + "/secure-core-vpn", + "Multi-hop VPN routing through hardened locations.", +); + +export default function SecureCorePage() { + return ( + + Use cases + When it helps + + Trade-offs + +

    + Each hop adds milliseconds. Use standard servers for gaming or + latency-sensitive calls; enable Secure Core when the threat model + warrants it. +

    +
    +
    + ); +} diff --git a/src/app/(site)/security-model/page.tsx b/src/app/(site)/security-model/page.tsx new file mode 100644 index 00000000..c59273f9 --- /dev/null +++ b/src/app/(site)/security-model/page.tsx @@ -0,0 +1,58 @@ +import { + DocFeatureGrid, + DocH2, + DocH3, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { MapPin, Shield, ShieldOff, Wifi } from "lucide-react"; + +export const metadata = docMetadata( + "Security model", + "/security-model", + "What the VPN protects — and what it does not.", +); + +export default function SecurityModelPage() { + return ( + + Protection scope + In scope + + Out of scope + Endpoint compromise +

    + Malware with root access can read keystrokes and screen contents before + traffic ever reaches the VPN. +

    + Token phishing +

    + Training and password managers matter as much as tunnel encryption. +

    + Legal compulsion +

    + Providers respond to lawful requests within their jurisdiction. Read + transparency reporting for historical examples. +

    +
    + ); +} diff --git a/src/app/(site)/sign-in/page.tsx b/src/app/(site)/sign-in/page.tsx new file mode 100644 index 00000000..905b8d67 --- /dev/null +++ b/src/app/(site)/sign-in/page.tsx @@ -0,0 +1,90 @@ +import { docMetadata } from "@/lib/page-meta"; +import { syne } from "@/lib/proton-fonts"; +import { cn } from "@/lib/utils"; +import { Shield, KeyRound, ArrowRight } from "lucide-react"; + +export const metadata = docMetadata( + "Enter Token", + "/sign-in", + "Access your anonymous VPN session via token.", +); + +export default function SignInPage() { + return ( +
    +
    + +
    +
    +
    + +
    +
    + +

    + Anonymous Access +

    +

    + No email, no password. Just enter the unique token generated by your + VPN app to manage your subscription. +

    + +
    +
    +
    + +
    +
    + +
    + +
    +

    + You can find your token in the app settings after installation. +

    +
    + + +
    +
    + +
    + Don't have a token yet?{" "} + + Download the app + {" "} + first. +
    +
    +
    + ); +} diff --git a/src/app/(site)/streaming/page.tsx b/src/app/(site)/streaming/page.tsx new file mode 100644 index 00000000..36b6809a --- /dev/null +++ b/src/app/(site)/streaming/page.tsx @@ -0,0 +1,58 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { Lightbulb, RefreshCw, Server, Tv } from "lucide-react"; + +export const metadata = docMetadata( + "Streaming", + "/streaming", + "Using a VPN with streaming services.", +); + +export default function StreamingPage() { + return ( + + Getting started + Practical tips + + Platform-specific notes +

    + Smart TVs and consoles may need router-level VPN, a dedicated TV app, or + DNS-based approaches depending on hardware. Desktop and mobile apps are + the simplest starting point — get builds from{" "} + Download. +

    + +

    + Netflix VPN{" "} + — focused notes on catalogue behaviour and compliance. +

    +
    +
    + ); +} diff --git a/src/app/(site)/support/page.tsx b/src/app/(site)/support/page.tsx new file mode 100644 index 00000000..591e1619 --- /dev/null +++ b/src/app/(site)/support/page.tsx @@ -0,0 +1,92 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { + CreditCard, + Flame, + Gauge, + Monitor, + Shield, + Wifi, + Zap, +} from "lucide-react"; + +export const metadata = docMetadata( + "Support", + "/support", + "Help topics, troubleshooting, and where to look first.", +); + +export default function SupportPage() { + return ( + + Troubleshooting + Connection and speed + + + Token and billing + + + Devices and installs +

    + Platform-specific packages and extension builds are listed on the{" "} + Download hub. Each + section has an anchor (for example{" "} + + #windows + + ) for deep links from the header menu. +

    + + +

    + Quick answers for streaming, installs, legality, and device limits:{" "} + + Frequently asked questions + + . +

    +
    +
    + ); +} diff --git a/src/app/(site)/transparency/page.tsx b/src/app/(site)/transparency/page.tsx new file mode 100644 index 00000000..90ee4c30 --- /dev/null +++ b/src/app/(site)/transparency/page.tsx @@ -0,0 +1,50 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { FileText, Info, Shield } from "lucide-react"; + +export const metadata = docMetadata( + "Transparency", + "/transparency", + "Disclosure practices and accountability.", +); + +export default function TransparencyPage() { + return ( + + Report contents + What you typically find + + +

    + This static site does not publish live numbers. In production, replace + this section with PDF or HTML reports dated by quarter, and link + archived copies for year-over-year comparison. +

    +
    +
    + ); +} diff --git a/src/app/(site)/vpn-servers/page.tsx b/src/app/(site)/vpn-servers/page.tsx new file mode 100644 index 00000000..7f85e319 --- /dev/null +++ b/src/app/(site)/vpn-servers/page.tsx @@ -0,0 +1,58 @@ +import { + DocFeatureGrid, + DocH2, + DocH3, + DocSectionLabel, + DocStat, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { Globe2, Lock, Podcast, Shuffle } from "lucide-react"; + +export const metadata = docMetadata( + "VPN servers", + "/vpn-servers", + "Locations, capacity, and how servers are used.", +); + +export default function VpnServersPage() { + return ( + + + Network + Why location diversity matters +

    + Closer servers often mean lower round-trip time; distant servers help + when you need a specific region for testing or content availability. + Automatic "fastest server" picks balance load and distance on your + behalf. +

    + Special-purpose groups + +
    + ); +} diff --git a/src/app/(site)/what-is-a-vpn/page.tsx b/src/app/(site)/what-is-a-vpn/page.tsx new file mode 100644 index 00000000..dee0992d --- /dev/null +++ b/src/app/(site)/what-is-a-vpn/page.tsx @@ -0,0 +1,59 @@ +import { + DocCallout, + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { Globe, KeyRound, LogIn, Zap } from "lucide-react"; + +export const metadata = docMetadata( + "What is a VPN", + "/what-is-a-vpn", + "Virtual private networks explained in plain language.", +); + +export default function WhatIsVpnPage() { + return ( + + How it works + Step by step + + What changes for you +

    + Websites see the VPN egress IP. Your ISP sees encrypted blobs to the + VPN, not final destinations. DNS may be handled by the VPN resolver to + reduce leaks — verify with the app’s leak test tools. +

    + +

    + On the home page, open{" "} + How a VPN works for the + illustrated section, then explore{" "} + Features. +

    +
    +
    + ); +} diff --git a/src/app/(site)/what-is-my-ip/page.tsx b/src/app/(site)/what-is-my-ip/page.tsx new file mode 100644 index 00000000..1a76080c --- /dev/null +++ b/src/app/(site)/what-is-my-ip/page.tsx @@ -0,0 +1,52 @@ +import { + DocFeatureGrid, + DocH2, + DocInlineLink, + DocSectionLabel, +} from "@/components/proton/DocBlocks"; +import { SimpleDocPage } from "@/components/proton/SimpleDocPage"; +import { docMetadata } from "@/lib/page-meta"; +import { ROUTES } from "@/lib/site-routes"; +import { Cookie, MapPin, Network, Terminal } from "lucide-react"; + +export const metadata = docMetadata( + "What is my IP", + "/what-is-my-ip", + "Public IP address — demo explainer.", +); + +export default function WhatIsMyIpPage() { + return ( + + Context + Why it matters + + Implementation note +

    + A production widget would call a minimal API (ideally your own edge + function) that echoes the client address and ASN. Until then, connect + through{" "} + the VPN apps and + use the built-in leak checker where available. +

    +
    + ); +} diff --git a/src/components/proton/DocBlocks.tsx b/src/components/proton/DocBlocks.tsx new file mode 100644 index 00000000..7839da2b --- /dev/null +++ b/src/components/proton/DocBlocks.tsx @@ -0,0 +1,147 @@ +import Link from "next/link"; +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +export function DocH2({ + children, + icon: Icon, +}: { + children: ReactNode; + icon?: LucideIcon; +}) { + return ( +

    + {Icon ? ( + + ) : null} + {children} +

    + ); +} + +export function DocH3({ children }: { children: ReactNode }) { + return ( +

    {children}

    + ); +} + +export function DocUl({ items }: { items: string[] }) { + return ( +
      + {items.map((item) => ( +
    • {item}
    • + ))} +
    + ); +} + +export function DocCallout({ + title, + children, +}: { + title?: string; + children: ReactNode; +}) { + return ( +
    + {title ? ( +

    + {title} +

    + ) : null} +
    {children}
    +
    + ); +} + +export function DocInlineLink({ + href, + children, +}: { + href: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** Pill badge used above DocH2 to label a section. */ +export function DocSectionLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** 2-column responsive glass-card grid replacing DocUl on feature-heavy sections. */ +export function DocFeatureGrid({ + items, +}: { + items: { icon?: LucideIcon; text: string }[]; +}) { + return ( +
    + {items.map((item) => { + const Icon = item.icon; + return ( +
    + {Icon ? ( + + ) : ( + + )} + + {item.text} + +
    + ); + })} +
    + ); +} + +/** Horizontal stat highlight strip. */ +export function DocStat({ + items, +}: { + items: { value: string; label: string }[]; +}) { + return ( +
    + {items.map(({ value, label }) => ( +
    + + {value} + + + {label} + +
    + ))} +
    + ); +} diff --git a/src/components/ui/CtaModal.tsx b/src/components/ui/CtaModal.tsx new file mode 100644 index 00000000..75432833 --- /dev/null +++ b/src/components/ui/CtaModal.tsx @@ -0,0 +1,949 @@ +"use client"; + +import { type CtaVariant, useCtaModal } from "@/context/CtaModalContext"; +import { cn } from "@/lib/utils"; +import { BorderTrail } from "@/components/ui/border-trail"; +import { MagneticButton } from "@/components/ui/magnetic-button"; +import { + AnimatePresence, + motion, + useReducedMotion, + type Transition, + type Variants, +} from "motion/react"; +import { + ArrowRight, + CheckCircle2, + Globe, + Key, + Lock, + ShieldCheck, + Sparkles, + Wifi, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +/* ══════════════════════════════════════════════════════════════ + DATA — Windows-only install flow +═══════════════════════════════════════════════════════════════ */ + +type StepSpec = { + keys: readonly string[]; + label: string; + desc: string; +}; + +const WIN_STEPS: readonly StepSpec[] = [ + { + keys: ["Ctrl", "L"], + label: "Focus the address bar", + desc: "Ctrl+L highlights the Explorer address bar in the Downloads window.", + }, + { + keys: ["Ctrl", "V"], + label: "Paste the install command", + desc: "We already copied the signed command to your clipboard.", + }, + { + keys: ["Enter"], + label: "Press Enter to install", + desc: "Signed PowerShell script runs, verifies SHA-256, and launches AuraVPN.", + }, +]; + +const WINDOWS_META = { + label: "Windows", + fileName: "auravpn-win-x64-setup.exe", + size: "48 MB", + installCmd: `powershell -Command "iwr -useb https://auravpn.example/install.ps1 | iex"`, + steps: WIN_STEPS, +} as const; + +type Platform = "windows" | "other"; + +function detectPlatform(): Platform { + if (typeof navigator === "undefined") return "windows"; + const ua = navigator.userAgent.toLowerCase(); + const plat = (navigator.platform || "").toLowerCase(); + if (/win/.test(plat) || /windows/.test(ua)) return "windows"; + return "other"; +} + +/* ─── variant copy ─── */ +const VARIANT_CONFIG: Record< + CtaVariant, + { badge: string; title: string; desc: string } +> = { + free: { + badge: "Free", + title: "Install AuraVPN", + desc: "Click Try Free — then just three keystrokes. No accounts, no sign-up.", + }, + plus: { + badge: "Plus", + title: "Install AuraVPN Plus", + desc: "Click Try Free to copy the signed command — then three keystrokes to install. 10 Gbps · 15 000+ servers.", + }, + business: { + badge: "Business", + title: "Install AuraVPN for Business", + desc: "Click Try Free to copy the signed enrollment command — then three keystrokes. MDM-friendly.", + }, +}; + +/* ══════════════════════════════════════════════════════════════ + MOTION VARIANTS +═══════════════════════════════════════════════════════════════ */ + +const SPRING: Transition = { type: "spring", damping: 24, stiffness: 280 }; +const EASE_OUT: Transition = { duration: 0.36, ease: [0.16, 1, 0.3, 1] }; + +const shellVariants: Variants = { + hidden: { opacity: 0, y: 24, scale: 0.96 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { + ...SPRING, + when: "beforeChildren", + delayChildren: 0.16, + staggerChildren: 0.08, + }, + }, + exit: { opacity: 0, y: 12, scale: 0.97, transition: { duration: 0.18 } }, +}; + +const itemVariants: Variants = { + hidden: { opacity: 0, y: 8 }, + visible: { opacity: 1, y: 0, transition: EASE_OUT }, +}; + +const stepsListVariants: Variants = { + hidden: {}, + visible: { + transition: { delayChildren: 0.64, staggerChildren: 0.08 }, + }, +}; + +const stepVariants: Variants = { + hidden: { opacity: 0, y: 12 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] } }, +}; + +/* ══════════════════════════════════════════════════════════════ + MAIN COMPONENT +═══════════════════════════════════════════════════════════════ */ + +export function CtaModal() { + const { open, variant, closeModal } = useCtaModal(); + const dialogRef = useRef(null); + const fileInputRef = useRef(null); + const cfg = VARIANT_CONFIG[variant]; + const reduceMotion = useReducedMotion(); + + const [platform, setPlatform] = useState("windows"); + const [copied, setCopied] = useState(false); + const [copying, setCopying] = useState(false); + + useEffect(() => { + setPlatform(detectPlatform()); + }, []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") handleClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + useEffect(() => { + if (open) requestAnimationFrame(() => dialogRef.current?.focus()); + }, [open]); + + useEffect(() => { + if (!open) { + setCopied(false); + setCopying(false); + } + }, [open]); + + const handleClose = () => { + setCopied(false); + setCopying(false); + closeModal(); + }; + + const copyAndLaunch = async () => { + const cmd = WINDOWS_META.installCmd; + try { + setCopying(true); + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(cmd); + } else { + const ta = document.createElement("textarea"); + ta.value = cmd; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + // small cosmetic delay so progress ring is perceivable + await new Promise((r) => setTimeout(r, 260)); + setCopied(true); + setCopying(false); + + type PickerWindow = Window & { + showOpenFilePicker?: (opts?: { startIn?: string }) => Promise; + }; + const w = window as PickerWindow; + if (typeof w.showOpenFilePicker === "function") { + try { + await w.showOpenFilePicker({ startIn: "downloads" }); + return; + } catch (err) { + const name = (err as { name?: string })?.name; + // user cancelled — do NOT fallback, second picker would be a bug + if (name === "AbortError") return; + // permission denied / security — also no fallback, second picker won't help + if (name === "SecurityError" || name === "NotAllowedError") return; + // unsupported / unknown error — fall through to input fallback + } + } + fileInputRef.current?.click(); + } catch { + setCopying(false); + setCopied(false); + } + }; + + return ( + + {open && ( +
    + {/* ── backdrop ── */} + + + {/* ── mesh gradient orbs ── */} + + + + + {/* ── modal shell ── */} + + + + + {/* soft emerald edge bloom */} + + + +
    + {/* ── LEFT PANEL: VPN Orbit ── */} + + + + + {/* ── RIGHT PANEL: content ── */} +
    + {/* ── header ── */} + +
    + + +
    + + +
    + + {/* ── title block ── */} +
    + + {cfg.title} + + + {cfg.desc} + +
    + + {/* ── file meta row ── */} + + + {WINDOWS_META.fileName} + + + {WINDOWS_META.size} · sha-256 7f3a…b91c + + + + {/* ── hero button + launched chip ── */} +
    + + {copied && } +
    + + {/* ── section divider ── */} + + + {/* ── stepper cards ── */} + + {WIN_STEPS.map((step, i) => ( +
    + + {i < WIN_STEPS.length - 1 && ( + + )} +
    + ))} +
    + + {/* ── non-windows notice ── */} + {platform === "other" && ( + + +

    + Windows installer shown. macOS & Linux builds are rolling + out — your clipboard will still receive the signed command. +

    +
    + )} + + {/* hairline */} + + + {/* ── footer ── */} + + + + SHA-256 verified · Auto-update enabled + + + +
    +
    +
    +
    + )} +
    + ); +} + +/* ══════════════════════════════════════════════════════════════ + VPN ORBIT — left panel: shield + orbiting icons +═══════════════════════════════════════════════════════════════ */ + +const ORBIT_ICONS = [ + { Icon: Lock, angle: 0, delay: 0 }, + { Icon: Globe, angle: 120, delay: 0.5 }, + { Icon: Key, angle: 240, delay: 1 }, +] as const; + +function VPNOrbit({ + reduceMotion, + acknowledged, +}: { + reduceMotion: boolean; + acknowledged: boolean; +}) { + return ( +
    + {/* radial bleed — soft emerald halo behind everything */} + + + {/* outer pulse ring — master beat 4.8s (flashes on acknowledged) */} + + + {/* inner pulse ring — phase offset 1.6s (rolling wave) */} + + + {/* orbit ring (dashed, static) */} + + + {/* orbiting icons */} + {ORBIT_ICONS.map(({ Icon, angle, delay }, i) => ( + + + + + + ))} + + {/* central shield — breathing at master beat 4.8s, flash on acknowledged */} + + + + + + {/* bottom meta */} +
    +
    + + + secured tunnel + +
    + + wireguard · sha-256 + +
    +
    + ); +} + +/* ══════════════════════════════════════════════════════════════ + MESH ORBS — drifting emerald + violet bleed +═══════════════════════════════════════════════════════════════ */ + +function MeshOrbs({ reduceMotion }: { reduceMotion: boolean }) { + return ( + <> + + + + ); +} + +/* ══════════════════════════════════════════════════════════════ + NOISE — SVG turbulence, film grain +═══════════════════════════════════════════════════════════════ */ + +function NoiseLayer() { + return ( + + + + + + + + ); +} + +/* ══════════════════════════════════════════════════════════════ + AURORA BACKLIGHT — slow-rotating emerald atmosphere inside shell +═══════════════════════════════════════════════════════════════ */ + +function AuroraBacklight({ reduceMotion }: { reduceMotion: boolean }) { + return ( +
    + +
    + ); +} + +/* ══════════════════════════════════════════════════════════════ + STATUS DOT +═══════════════════════════════════════════════════════════════ */ + +function StatusDot({ active }: { active: boolean }) { + return ( + + {active && ( + + )} + + + ); +} + +/* ══════════════════════════════════════════════════════════════ + HYPER SCRAMBLE — A-Z glitch badge +═══════════════════════════════════════════════════════════════ */ + +const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split(""); +const randChar = () => ALPHA[Math.floor(Math.random() * ALPHA.length)]!; + +function HyperScramble({ + target, + reduceMotion, +}: { + target: string; + reduceMotion: boolean; +}) { + const [display, setDisplay] = useState(target); + + useEffect(() => { + if (reduceMotion) return; + let iter = 0; + const total = target.length; + const id = window.setInterval(() => { + iter += 0.5; + const next = target + .split("") + .map((ch, i) => { + if (ch === " " || ch === "·") return ch; + if (i < iter) return target[i]!; + return randChar(); + }) + .join(""); + setDisplay(next); + if (iter >= total) { + setDisplay(target); + window.clearInterval(id); + } + }, 40); + return () => window.clearInterval(id); + }, [target, reduceMotion]); + + return ( + + {reduceMotion ? target : display} + + ); +} + +/* ══════════════════════════════════════════════════════════════ + HERO BUTTON — dominant primary action, stays visible + dims on click +═══════════════════════════════════════════════════════════════ */ + +function HeroButton({ + state, + onClick, +}: { + state: "idle" | "copying" | "copied"; + onClick: () => void; +}) { + const isBusy = state === "copying"; + + return ( + + + {/* inner ring */} + + + + Try Free + + + + + ); +} + +/* ══════════════════════════════════════════════════════════════ + LAUNCHED CHIP — confirmation pill above hero button +═══════════════════════════════════════════════════════════════ */ + +function LaunchedChip() { + return ( + + + Installer launched + + ); +} + +/* ══════════════════════════════════════════════════════════════ + SECTION DIVIDER — separates hero from stepper +═══════════════════════════════════════════════════════════════ */ + +function SectionDivider() { + return ( + + + + + + Then in File Explorer + + + + + ); +} + +/* ══════════════════════════════════════════════════════════════ + STEP CARD — vertical instruction card (dim default, active on copied) +═══════════════════════════════════════════════════════════════ */ + +function StepCard({ + step, + index, + active, +}: { + step: StepSpec; + index: number; + active: boolean; +}) { + return ( + + {/* number chip */} + + {`0${index + 1}`} + + + {/* label + desc */} +
    +

    + {step.label} +

    +

    + {step.desc} +

    +
    + + {/* keycaps */} +
    + {step.keys.map((key, i) => ( +
    + {i > 0 && ( + + + )} + 1 ? "min-w-[32px]" : "min-w-[24px]", + active + ? "border-emerald-400/30 bg-emerald-400/10 text-emerald-200" + : "border-white/[0.08] bg-white/[0.02] text-zinc-400", + )} + > + {key} + +
    + ))} +
    +
    + ); +} + +/* ══════════════════════════════════════════════════════════════ + STEPPER CONNECTOR — vertical line between step cards +═══════════════════════════════════════════════════════════════ */ + +function StepperConnector({ active }: { active: boolean }) { + return ( + + ); +} +