From ddeecef6311f714b54fcd8caa9d9922dbde41746 Mon Sep 17 00:00:00 2001 From: Alejandro Veras Date: Thu, 8 Oct 2026 15:44:57 -0400 Subject: [PATCH] Build the AI Growth Technologies homepage Dark cinematic redesign: animated globe hero, stats, interactive capabilities engine, proprietary technology visuals, AI discovery, attribution journey, connections manifesto, CTA and footer. --- public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - src/app/globals.css | 1808 +++++++++++++++++++++++++++- src/app/layout.tsx | 40 +- src/app/page.tsx | 525 +++++++- src/components/capabilities.tsx | 107 ++ src/components/globe-canvas.tsx | 270 +++++ src/components/logo.tsx | 22 + src/components/product-visuals.tsx | 186 +++ src/components/query-ticker.tsx | 57 + src/components/reveal-observer.tsx | 25 + src/components/site-header.tsx | 86 ++ src/components/stat-counter.tsx | 53 + src/content/home.ts | 156 +++ 17 files changed, 3242 insertions(+), 98 deletions(-) delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg create mode 100644 src/components/capabilities.tsx create mode 100644 src/components/globe-canvas.tsx create mode 100644 src/components/logo.tsx create mode 100644 src/components/product-visuals.tsx create mode 100644 src/components/query-ticker.tsx create mode 100644 src/components/reveal-observer.tsx create mode 100644 src/components/site-header.tsx create mode 100644 src/components/stat-counter.tsx create mode 100644 src/content/home.ts diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..cc6aaf6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,1804 @@ @import "tailwindcss"; +/* ============================================================ + Design tokens — dark cinematic system + ============================================================ */ :root { - --background: #ffffff; - --foreground: #171717; + --rosso: #da291c; + --rosso-scuro: #9d2211; + --bianco: #ffffff; + --nero: #000000; + --notte: #181818; + --grafite: #303030; + --fumo: #8f8f8f; + --piombo: #666666; + --argento: #d2d2d2; + + --font-sans: var(--font-inter), "Helvetica Neue", Arial, sans-serif; + + --gutter: clamp(20px, 4.2vw, 60px); + --max: 1440px; + --section-y: clamp(88px, 9vw, 136px); + --ease: cubic-bezier(0.2, 0.7, 0.1, 1); } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +*, +*::before, +*::after { + box-sizing: border-box; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +html { + scroll-behavior: smooth; + scroll-padding-top: 72px; + -webkit-text-size-adjust: 100%; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + margin: 0; + background: var(--nero); + color: var(--bianco); + font-family: var(--font-sans); + font-feature-settings: "ss01" on, "cv11" on; + font-size: 15px; + line-height: 1.7; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +::selection { + background: var(--rosso); + color: var(--bianco); +} + +a { + color: inherit; + text-decoration: none; +} + +:focus-visible { + outline: 1px solid var(--rosso); + outline-offset: 4px; +} + +.wrap { + width: 100%; + max-width: var(--max); + margin-inline: auto; + padding-inline: var(--gutter); +} + +.skip-link { + position: absolute; + left: 16px; + top: -60px; + z-index: 100; + padding: 12px 18px; + background: var(--bianco); + color: var(--nero); + font-size: 11px; + letter-spacing: 0.2em; + text-transform: uppercase; +} + +.skip-link:focus { + top: 16px; +} + +/* ============================================================ + Typography + ============================================================ */ +.eyebrow { + display: inline-flex; + align-items: center; + gap: 14px; + margin: 0; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--fumo); +} + +.eyebrow::before { + content: ""; + width: 24px; + height: 1px; + background: var(--rosso); +} + +.eyebrow .idx { + color: var(--bianco); +} + +.display { + margin: 0; + font-weight: 400; + text-transform: uppercase; + letter-spacing: -0.012em; + line-height: 1.02; +} + +.h1 { + font-size: clamp(40px, 5.6vw, 92px); + line-height: 0.98; + letter-spacing: -0.022em; +} + +.h2 { + font-size: clamp(30px, 4.2vw, 64px); +} + +.h3 { + font-size: clamp(22px, 2.1vw, 30px); + line-height: 1.12; + letter-spacing: -0.005em; +} + +.muted { + color: var(--fumo); +} + +.lead { + font-size: clamp(17px, 1.45vw, 21px); + line-height: 1.55; + font-weight: 400; + letter-spacing: -0.005em; + color: var(--bianco); +} + +.copy { + font-size: 15px; + line-height: 1.75; + color: var(--fumo); +} + +.copy p, +.lead p { + margin: 0; +} + +.copy p + p, +.lead p + p { + margin-top: 1em; +} + +.copy .hl { + color: var(--bianco); +} + +/* ============================================================ + Buttons & links + ============================================================ */ +.btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 18px; + height: 54px; + padding: 0 28px; + border: 1px solid var(--bianco); + border-radius: 0; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + white-space: nowrap; + transition: + background-color 0.35s var(--ease), + color 0.35s var(--ease), + border-color 0.35s var(--ease); +} + +.btn .arrow { + transition: transform 0.35s var(--ease); +} + +.btn:hover .arrow { + transform: translateX(4px); +} + +.btn-solid { + background: var(--bianco); + color: var(--nero); +} + +.btn-solid:hover { + background: transparent; + color: var(--bianco); + border-color: var(--rosso); +} + +.btn-ghost { + background: transparent; + color: var(--bianco); + border-color: rgba(255, 255, 255, 0.35); +} + +.btn-ghost:hover { + border-color: var(--bianco); +} + +.arrow-link { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 6px 0; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--bianco); + border-bottom: 1px solid transparent; + transition: + color 0.3s var(--ease), + border-color 0.3s var(--ease); +} + +.arrow-link .arrow { + transition: transform 0.3s var(--ease); +} + +.arrow-link:hover { + color: var(--rosso); + border-color: var(--rosso); +} + +.arrow-link:hover .arrow { + transform: translateX(4px); +} + +.pill { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 9999px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--bianco); + white-space: nowrap; +} + +/* ============================================================ + Reveal on scroll + ============================================================ */ +[data-reveal] { + opacity: 0; + transform: translateY(28px); + transition: + opacity 1.1s var(--ease), + transform 1.1s var(--ease); + transition-delay: var(--d, 0ms); +} + +[data-reveal].is-in { + opacity: 1; + transform: none; +} + +/* ============================================================ + Header + ============================================================ */ +.site-header { + position: fixed; + inset: 0 0 auto 0; + z-index: 50; + border-bottom: 1px solid transparent; + transition: + background-color 0.5s var(--ease), + border-color 0.5s var(--ease), + transform 0.5s var(--ease); +} + +.site-header.is-scrolled { + background: rgba(0, 0, 0, 0.82); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-color: var(--grafite); +} + +.site-header.is-hidden { + transform: translateY(-100%); +} + +.site-header .bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 76px; + gap: 24px; +} + +.logo { + display: inline-flex; + align-items: center; + gap: 14px; +} + +.logo-mark { + width: 30px; + height: 30px; + flex: none; +} + +.logo-type { + display: flex; + flex-direction: column; + line-height: 1; +} + +.logo-type b { + font-size: 14px; + font-weight: 600; + letter-spacing: 0.2em; +} + +.logo-type span { + margin-top: 5px; + font-size: 8.5px; + font-weight: 500; + letter-spacing: 0.42em; + color: var(--fumo); +} + +.nav { + display: flex; + align-items: center; + gap: clamp(20px, 2.6vw, 44px); +} + +.nav a { + position: relative; + padding: 5px 0; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.78); + transition: color 0.3s var(--ease); +} + +.nav a::after { + content: ""; + position: absolute; + left: 0; + right: 100%; + bottom: 0; + height: 1px; + background: var(--rosso); + transition: right 0.35s var(--ease); +} + +.nav a:hover { + color: var(--bianco); +} + +.nav a:hover::after { + right: 0; +} + +.header-cta { + height: 42px; + padding: 0 20px; +} + +.menu-toggle { + display: none; + width: 44px; + height: 44px; + padding: 0; + border: 0; + background: transparent; + color: var(--bianco); + cursor: pointer; +} + +.menu-toggle span { + display: block; + width: 22px; + height: 1px; + margin: 6px auto; + background: currentColor; + transition: transform 0.35s var(--ease); +} + +.menu-toggle[aria-expanded="true"] span:first-child { + transform: translateY(3.5px) rotate(45deg); +} + +.menu-toggle[aria-expanded="true"] span:last-child { + transform: translateY(-3.5px) rotate(-45deg); +} + +.mobile-menu { + position: fixed; + inset: 76px 0 0 0; + z-index: 49; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 32px var(--gutter) 40px; + background: var(--nero); + border-top: 1px solid var(--grafite); + opacity: 0; + visibility: hidden; + transition: + opacity 0.4s var(--ease), + visibility 0.4s; +} + +.mobile-menu.is-open { + opacity: 1; + visibility: visible; +} + +.mobile-menu ul { + list-style: none; + margin: 0; + padding: 0; +} + +.mobile-menu li { + border-bottom: 1px solid var(--grafite); +} + +.mobile-menu li a { + display: flex; + justify-content: space-between; + padding: 22px 0; + font-size: 22px; + font-weight: 400; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +@media (max-width: 1023px) { + .nav, + .header-cta { + display: none; + } + .menu-toggle { + display: block; + } +} + +/* ============================================================ + Hero + ============================================================ */ +.hero { + position: relative; + min-height: 100svh; + display: flex; + flex-direction: column; + justify-content: flex-end; + overflow: hidden; + background: var(--nero); + isolation: isolate; +} + +.hero-canvas { + position: absolute; + inset: 0; + z-index: -2; + width: 100%; + height: 100%; +} + +.hero::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, transparent 22%), + linear-gradient(0deg, #000 4%, rgba(0, 0, 0, 0.6) 34%, transparent 62%), + linear-gradient(90deg, rgba(0, 0, 0, 0.7) 0%, transparent 55%); + pointer-events: none; +} + +.hero-inner { + padding-top: 132px; + padding-bottom: 40px; +} + +.hero h1 { + max-width: 17ch; + margin-top: 28px; +} + +.hero-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + margin-top: clamp(40px, 5vw, 72px); + align-items: end; +} + +.hero-lead { + grid-column: 1 / span 5; +} + +.hero-body { + grid-column: 7 / span 5; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 32px; +} + +.hero-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: clamp(48px, 6vw, 88px); + padding-top: 22px; + border-top: 1px solid var(--grafite); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--fumo); +} + +.hero-channels { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; +} + +.hero-channels i { + width: 4px; + height: 4px; + background: var(--grafite); +} + +.scroll-cue { + display: inline-flex; + align-items: center; + gap: 14px; +} + +.scroll-cue i { + position: relative; + display: block; + width: 1px; + height: 36px; + overflow: hidden; + background: var(--grafite); +} + +.scroll-cue i::after { + content: ""; + position: absolute; + left: 0; + top: -40%; + width: 1px; + height: 40%; + background: var(--bianco); + animation: cue 2.2s var(--ease) infinite; +} + +@keyframes cue { + to { + top: 100%; + } +} + +@media (max-width: 1023px) { + .hero-lead, + .hero-body { + grid-column: 1 / -1; + } + .hero-channels { + display: none; + } +} + +@media (max-width: 640px) { + .hero-actions .btn { + flex: 1 1 100%; + } +} + +/* ============================================================ + Section scaffolding + ============================================================ */ +.section { + position: relative; + padding-block: var(--section-y); +} + +.section-night { + background: var(--notte); +} + +.section-head { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + align-items: end; +} + +.section-head .title { + grid-column: 1 / span 8; +} + +.section-head .title .display { + margin-top: 28px; +} + +.section-head .aside { + grid-column: 9 / span 4; +} + +@media (max-width: 1023px) { + .section-head .title, + .section-head .aside { + grid-column: 1 / -1; + } +} + +/* two-column copy row used throughout */ +.split { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + margin-top: clamp(40px, 5vw, 72px); +} + +.split > :first-child { + grid-column: 1 / span 5; +} + +.split > :last-child { + grid-column: 7 / span 5; +} + +@media (max-width: 1023px) { + .split > :first-child, + .split > :last-child { + grid-column: 1 / -1; + } +} + +/* ============================================================ + Stats + ============================================================ */ +.stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: clamp(56px, 7vw, 104px); + border-top: 1px solid var(--grafite); +} + +.stat { + padding: 36px 28px 8px; + border-left: 1px solid var(--grafite); +} + +.stat:first-child { + border-left: 0; + padding-left: 0; +} + +.stat-value { + display: flex; + align-items: flex-start; + font-size: clamp(56px, 7.4vw, 120px); + font-weight: 300; + line-height: 1; + letter-spacing: -0.045em; + font-variant-numeric: tabular-nums; +} + +.stat-value small { + margin-left: 6px; + font-size: 0.36em; + font-weight: 400; + line-height: 1.3; + letter-spacing: 0; + color: var(--fumo); +} + +.stat-label { + margin-top: 22px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--fumo); +} + +@media (max-width: 900px) { + .stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .stat:nth-child(3) { + border-left: 0; + padding-left: 0; + } + .stat:nth-child(n + 3) { + border-top: 1px solid var(--grafite); + } + .stat { + padding-bottom: 32px; + } +} + +/* ============================================================ + Capabilities + ============================================================ */ +.cap-layout { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + margin-top: clamp(64px, 8vw, 120px); + border-top: 1px solid var(--grafite); +} + +.cap-list { + grid-column: 1 / span 6; + list-style: none; + margin: 0; + padding: 0; +} + +.cap-item { + border-bottom: 1px solid var(--grafite); +} + +.cap-trigger { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: center; + width: 100%; + padding: 26px 0; + border: 0; + background: transparent; + color: var(--piombo); + font: inherit; + text-align: left; + cursor: pointer; + transition: color 0.4s var(--ease); +} + +.cap-trigger .num { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + font-variant-numeric: tabular-nums; + transition: color 0.4s var(--ease); +} + +.cap-trigger .name { + font-size: clamp(22px, 2.4vw, 36px); + font-weight: 400; + line-height: 1.1; + letter-spacing: -0.01em; + text-transform: uppercase; +} + +.cap-trigger .tick { + width: 28px; + height: 1px; + background: var(--rosso); + transform: scaleX(0); + transform-origin: right; + transition: transform 0.45s var(--ease); +} + +.cap-trigger:hover, +.cap-item.is-active .cap-trigger { + color: var(--bianco); +} + +.cap-item.is-active .cap-trigger .num { + color: var(--rosso); +} + +.cap-item.is-active .cap-trigger .tick { + transform: scaleX(1); +} + +.cap-inline { + display: none; +} + +.cap-panel { + grid-column: 8 / span 5; + position: sticky; + top: 100px; + align-self: start; + padding-top: 40px; +} + +.cap-map { + display: block; + width: 100%; + max-width: 260px; + margin-bottom: 32px; + overflow: visible; +} + +.cap-map line { + stroke: #262626; + stroke-width: 1; + transition: stroke 0.5s var(--ease); +} + +.cap-map line.on { + stroke: rgba(255, 255, 255, 0.6); +} + +.cap-map .node { + fill: var(--nero); + stroke: var(--piombo); + transition: + fill 0.4s var(--ease), + stroke 0.4s var(--ease); +} + +.cap-map .node.on { + fill: var(--bianco); + stroke: var(--bianco); +} + +.cap-map .core { + fill: var(--nero); + stroke: rgba(255, 255, 255, 0.4); +} + +.cap-map text { + fill: var(--fumo); + font-size: 7px; + font-weight: 500; + letter-spacing: 0.22em; +} + +.cap-map .pulse { + fill: none; + stroke: var(--rosso); + stroke-width: 1; + transform-box: fill-box; + transform-origin: center; + animation: ring 2.4s var(--ease) infinite; +} + +@keyframes ring { + from { + transform: scale(1); + opacity: 0.9; + } + to { + transform: scale(3.4); + opacity: 0; + } +} + +.cap-detail { + animation: fade-up 0.6s var(--ease); +} + +.cap-detail .kicker { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--fumo); +} + +.cap-detail h3 { + margin: 14px 0 22px; +} + +.cap-detail .copy { + max-width: 46ch; +} + +.cap-detail .arrow-link { + margin-top: 28px; +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (max-width: 1023px) { + .cap-list { + grid-column: 1 / -1; + } + .cap-panel { + display: none; + } + .cap-trigger { + grid-template-columns: 44px 1fr; + color: var(--bianco); + cursor: default; + padding-bottom: 14px; + } + .cap-trigger .num { + color: var(--rosso); + } + .cap-trigger .tick { + display: none; + } + .cap-inline { + display: block; + padding: 0 0 30px 44px; + } + .cap-inline h3 { + margin: 0 0 14px; + font-size: 17px; + font-weight: 500; + line-height: 1.35; + } + .cap-inline .arrow-link { + margin-top: 16px; + } +} + +/* ============================================================ + Technology + ============================================================ */ +.products { + margin-top: clamp(72px, 9vw, 136px); + border-top: 1px solid var(--grafite); +} + +.product { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + padding-block: clamp(48px, 6vw, 88px); + border-bottom: 1px solid var(--grafite); + align-items: center; +} + +.product-visual { + grid-column: 1 / span 6; + position: relative; + aspect-ratio: 600 / 412; + background: #070707; + outline: 1px solid #1c1c1c; + overflow: hidden; +} + +.product-body { + grid-column: 8 / span 5; +} + +.product:nth-child(even) .product-visual { + grid-column: 7 / span 6; + grid-row: 1; +} + +.product:nth-child(even) .product-body { + grid-column: 1 / span 5; + grid-row: 1; +} + +.product-kind { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 28px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--piombo); +} + +.product-name { + margin: 0; + font-size: clamp(48px, 5.6vw, 88px); + font-weight: 300; + line-height: 1; + letter-spacing: -0.04em; +} + +.product-body .h3 { + margin: 28px 0 18px; +} + +.product-body .arrow-link { + margin-top: 28px; +} + +.viz { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.viz text { + fill: var(--fumo); + font-size: 7.5px; + font-weight: 500; + letter-spacing: 0.22em; +} + +.viz .faint { + fill: var(--piombo); +} + +/* journey viz */ +.viz-journey .path { + fill: none; + stroke: rgba(255, 255, 255, 0.16); + stroke-width: 1; +} + +.viz-journey .flow { + fill: none; + stroke: var(--bianco); + stroke-width: 1.4; + stroke-dasharray: 24 600; + animation: flow 4.8s linear infinite; + animation-delay: var(--t, 0s); +} + +.viz-journey .flow.red { + stroke: var(--rosso); +} + +@keyframes flow { + from { + stroke-dashoffset: 624; + } + to { + stroke-dashoffset: 0; + } +} + +.viz-journey .node { + fill: var(--nero); + stroke: rgba(255, 255, 255, 0.6); +} + +.viz-journey .goal { + fill: var(--bianco); +} + +.viz-journey .goal-ring { + fill: none; + stroke: var(--rosso); + transform-box: fill-box; + transform-origin: center; + animation: ring 2.4s var(--ease) infinite; +} + +/* radar viz */ +.viz-radar .ring { + fill: none; + stroke: rgba(255, 255, 255, 0.12); +} + +.viz-radar .axis { + stroke: rgba(255, 255, 255, 0.07); +} + +.viz-radar .sweep { + transform-origin: 300px 206px; + animation: spin 7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.viz-radar .dot { + fill: var(--bianco); + animation: blip 7s linear infinite; + animation-delay: var(--t); +} + +.viz-radar .dot.red { + fill: var(--rosso); +} + +@keyframes blip { + 0%, + 100% { + opacity: 0.2; + } + 3% { + opacity: 1; + } + 35% { + opacity: 0.2; + } +} + +/* grid viz */ +.viz-grid .cell { + fill: rgba(255, 255, 255, 0.035); + stroke: rgba(255, 255, 255, 0.08); +} + +.viz-grid .cell.on { + fill: rgba(255, 255, 255, 0.85); + stroke: none; + animation: cell 4.8s var(--ease) infinite; + animation-delay: var(--t); +} + +.viz-grid .cell.alert { + fill: var(--rosso); +} + +@keyframes cell { + 0%, + 100% { + opacity: 0.12; + } + 45%, + 55% { + opacity: 1; + } +} + +.viz-grid .scan { + animation: scan 4.8s linear infinite; +} + +@keyframes scan { + from { + transform: translateY(-80px); + } + to { + transform: translateY(440px); + } +} + +.viz-grid .frame { + fill: none; + stroke: rgba(255, 255, 255, 0.5); +} + +@media (max-width: 1023px) { + .product-visual, + .product-body, + .product:nth-child(even) .product-visual, + .product:nth-child(even) .product-body { + grid-column: 1 / -1; + grid-row: auto; + } + .product-body { + margin-top: 12px; + } +} + +/* ============================================================ + Discovery + ============================================================ */ +.discovery { + overflow: hidden; +} + +.discovery-stage { + position: relative; + margin-top: clamp(64px, 8vw, 112px); + padding: clamp(56px, 7vw, 104px) 0; + border-block: 1px solid var(--grafite); + text-align: center; +} + +.query-label { + font-size: 10px; + font-weight: 500; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--piombo); +} + +.query { + max-width: 980px; + min-height: 2.3em; + margin: 24px auto 0; + font-size: clamp(24px, 3.4vw, 48px); + font-weight: 300; + line-height: 1.15; + letter-spacing: -0.02em; +} + +.query .caret { + display: inline-block; + width: 2px; + height: 0.9em; + margin-left: 6px; + vertical-align: -0.08em; + background: var(--rosso); + animation: caret 1s steps(1) infinite; +} + +@keyframes caret { + 50% { + opacity: 0; + } +} + +.surfaces { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 24px; + margin-top: 56px; +} + +.surface { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.surface.left { + justify-content: flex-end; +} + +.surface-core { + position: relative; + display: grid; + place-items: center; + width: 96px; + height: 96px; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.surface-core::before, +.surface-core::after { + content: ""; + position: absolute; + top: 50%; + width: 24px; + height: 1px; + background: var(--grafite); +} + +.surface-core::before { + right: 100%; +} + +.surface-core::after { + left: 100%; +} + +@media (max-width: 1023px) { + .surfaces { + grid-template-columns: 1fr; + justify-items: center; + } + .surface, + .surface.left { + justify-content: center; + } + .surface-core::before, + .surface-core::after { + display: none; + } +} + +/* ============================================================ + Journey / Attribution + ============================================================ */ +.journey { + position: relative; + margin-top: clamp(64px, 8vw, 112px); +} + +.journey ol { + position: relative; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + list-style: none; + margin: 0; + padding: 0; +} + +.journey ol::before, +.journey ol::after { + content: ""; + position: absolute; + top: 6px; + left: 0; + right: 0; + height: 1px; + background: var(--grafite); +} + +.journey ol::after { + background: linear-gradient(90deg, rgba(255, 255, 255, 0.7), var(--rosso)); + transform: scaleX(0); + transform-origin: left; + transition: transform 2.6s var(--ease) 0.2s; +} + +.journey.is-in ol::after { + transform: scaleX(1); +} + +.journey li { + position: relative; + padding-right: 24px; +} + +.journey .dot { + position: relative; + z-index: 1; + display: block; + width: 13px; + height: 13px; + border: 1px solid var(--bianco); + background: var(--nero); + transition: background-color 0.4s var(--ease); + transition-delay: calc(var(--i) * 380ms + 300ms); +} + +.journey.is-in .dot { + background: var(--bianco); +} + +.journey li:last-child .dot { + border-color: var(--rosso); +} + +.journey.is-in li:last-child .dot { + background: var(--rosso); +} + +.journey .channel { + display: block; + margin-top: 28px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--fumo); +} + +.journey .step { + display: block; + max-width: 22ch; + margin-top: 10px; + font-size: 16px; + line-height: 1.45; + color: var(--bianco); +} + +@media (max-width: 1023px) { + .journey ol { + grid-template-columns: 1fr; + gap: 34px; + padding-left: 34px; + } + .journey ol::before, + .journey ol::after { + top: 0; + bottom: 0; + left: 6px; + right: auto; + width: 1px; + height: auto; + } + .journey ol::after { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), var(--rosso)); + transform: scaleY(0); + transform-origin: top; + } + .journey.is-in ol::after { + transform: scaleY(1); + } + .journey .dot { + position: absolute; + left: -34px; + top: 4px; + } + .journey .channel { + margin-top: 0; + } +} + +.credit > :first-child { + grid-column: 1 / span 6; +} + +.credit > :last-child { + grid-column: 8 / span 5; + align-self: end; +} + +@media (max-width: 1023px) { + .credit > :first-child, + .credit > :last-child { + grid-column: 1 / -1; + } +} + +.credit { + margin-top: clamp(80px, 10vw, 144px); + padding-top: clamp(48px, 6vw, 80px); + border-top: 1px solid var(--grafite); +} + +.credit .question { + font-size: clamp(30px, 4.2vw, 64px); +} + +.pullquote { + margin: 0; + padding-top: clamp(80px, 10vw, 144px); + text-align: center; +} + +.pullquote blockquote { + max-width: 24ch; + margin: 28px auto 0; + font-size: clamp(28px, 3.8vw, 56px); + font-weight: 300; + line-height: 1.12; + letter-spacing: -0.025em; +} + +.pullquote figcaption { + max-width: 52ch; + margin: 36px auto 0; +} + +.pullquote .arrow-link { + margin-top: 36px; +} + +/* ============================================================ + Connections + ============================================================ */ +.gaps { + list-style: none; + margin: clamp(56px, 7vw, 96px) 0 0; + padding: 0; + border-top: 1px solid var(--grafite); + counter-reset: gap; +} + +.gaps li { + display: grid; + grid-template-columns: 80px 1fr 120px; + align-items: center; + gap: 24px; + padding: 28px 0; + border-bottom: 1px solid var(--grafite); + counter-increment: gap; + font-size: clamp(18px, 1.9vw, 28px); + font-weight: 300; + line-height: 1.3; + letter-spacing: -0.012em; + transition: padding 0.5s var(--ease); +} + +.gaps li::before { + content: counter(gap, decimal-leading-zero); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + color: var(--piombo); +} + +.gaps .break { + justify-self: end; + width: 120px; + height: 14px; +} + +.gaps .break line { + stroke: rgba(255, 255, 255, 0.45); +} + +.gaps .break .cut { + stroke: var(--rosso); +} + +.gaps li:hover { + padding-left: 12px; +} + +@media (max-width: 720px) { + .gaps li { + grid-template-columns: 40px 1fr; + } + .gaps .break { + display: none; + } +} + +.nos { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: clamp(64px, 8vw, 104px); + border-top: 1px solid var(--grafite); +} + +.nos div { + padding: 32px 28px 0; + border-left: 1px solid var(--grafite); + font-size: 17px; + line-height: 1.5; +} + +.nos div:first-child { + padding-left: 0; + border-left: 0; +} + +.nos span { + display: block; + margin-bottom: 16px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--piombo); +} + +.closing { + max-width: 30ch; + margin-top: clamp(64px, 8vw, 104px); + font-size: clamp(24px, 2.6vw, 38px); + font-weight: 300; + line-height: 1.25; + letter-spacing: -0.02em; +} + +@media (max-width: 1023px) { + .nos { + grid-template-columns: 1fr; + } + .nos div, + .nos div:first-child { + padding: 28px 0; + border-left: 0; + border-bottom: 1px solid var(--grafite); + } +} + +/* ============================================================ + Final CTA + ============================================================ */ +.cta { + position: relative; + min-height: 100svh; + display: flex; + align-items: center; + overflow: hidden; + isolation: isolate; + padding-block: 120px; + text-align: center; +} + +.cta .hero-canvas { + opacity: 0.6; +} + +.cta::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.9) 55%, #000 80%); + pointer-events: none; +} + +.cta .display { + max-width: 17ch; + margin: 28px auto 0; +} + +.cta .copy { + max-width: 56ch; + margin: 40px auto 0; +} + +.cta .lead { + margin-top: 32px; +} + +.cta .btn { + margin-top: 48px; +} + +/* ============================================================ + Footer + ============================================================ */ +.footer { + background: var(--notte); + border-top: 1px solid var(--grafite); + padding-top: clamp(64px, 7vw, 104px); + overflow: hidden; +} + +.footer-top { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; +} + +.footer-brand { + grid-column: 1 / span 4; +} + +.footer-brand p { + max-width: 34ch; + margin: 28px 0 0; + font-size: 13px; + line-height: 1.75; + color: var(--fumo); +} + +.footer-cols { + grid-column: 6 / span 7; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} + +.footer h4 { + margin: 0 0 22px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.footer ul { + list-style: none; + margin: 0; + padding: 0; +} + +.footer li + li { + margin-top: 12px; +} + +.footer li a { + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--fumo); + transition: color 0.3s var(--ease); +} + +.footer li a:hover { + color: var(--rosso); +} + +.socials { + display: flex; + gap: 8px; + margin-top: 32px; +} + +.socials a { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border: 1px solid var(--grafite); + color: var(--bianco); + transition: + border-color 0.3s var(--ease), + color 0.3s var(--ease); +} + +.socials a:hover { + border-color: var(--rosso); + color: var(--rosso); +} + +.socials svg { + width: 16px; + height: 16px; +} + +.footer-word { + margin: clamp(64px, 8vw, 120px) 0 0; + font-size: min(7.1vw, 102px); + font-weight: 300; + line-height: 0.9; + letter-spacing: -0.05em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.07); + white-space: nowrap; + user-select: none; +} + +.footer-bottom { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 16px; + margin-top: 24px; + padding-block: 28px; + border-top: 1px solid var(--grafite); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--piombo); +} + +.footer-bottom nav { + display: flex; + gap: 28px; +} + +.footer-bottom a:hover { + color: var(--bianco); +} + +@media (max-width: 1023px) { + .footer-brand, + .footer-cols { + grid-column: 1 / -1; + } + .footer-cols { + margin-top: 40px; + } +} + +@media (max-width: 560px) { + .footer-cols { + grid-template-columns: 1fr 1fr; + } +} + +/* ============================================================ + Reduced motion + ============================================================ */ +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + transition-delay: 0ms !important; + } + [data-reveal] { + opacity: 1; + transform: none; + } +} + +@media (max-width: 767px) { + .hero .hero-canvas { + opacity: 0.45; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9852c15..fbf03c7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,29 +1,39 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], + display: "swap", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const description = + "AI Growth Technologies connects paid media, SEO, AI visibility, web, attribution, and technology into one accountable digital growth system."; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + metadataBase: new URL("https://www.aigrowthtechnologies.com"), + title: "AI Growth Technologies | Digital Growth for the AI Era", + description, + alternates: { canonical: "/" }, + openGraph: { + type: "website", + url: "/", + siteName: "AI Growth Technologies", + title: "AI Growth Technologies | Digital Growth for the AI Era", + description, + }, +}; + +export const viewport: Viewport = { + themeColor: "#000000", + colorScheme: "dark", }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - {children} + + {children} ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index c887311..c6210bf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,69 +1,468 @@ -import Image from "next/image"; +import type { CSSProperties, ReactNode } from "react"; +import SiteHeader from "@/components/site-header"; +import GlobeCanvas from "@/components/globe-canvas"; +import RevealObserver from "@/components/reveal-observer"; +import StatCounter from "@/components/stat-counter"; +import Capabilities from "@/components/capabilities"; +import QueryTicker from "@/components/query-ticker"; +import ProductVisual from "@/components/product-visuals"; +import Logo from "@/components/logo"; +import { stats, products, journeySteps, gaps, footerColumns } from "@/content/home"; + +type Vars = CSSProperties & Record<`--${string}`, string | number>; +const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars; + +const Arrow = () => →; + +function Eyebrow({ index, children }: { index?: string; children: ReactNode }) { + return ( +

+ {index && {index}} + {children} +

+ ); +} export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
+ <> + + Skip to content + + + + +
+ {/* ───────────────────────── Hero ───────────────────────── */} +
+ +
+
+ Digital Growth Architects +
+

+ Where Search, AI, Media, and Technology Work Together. +

+ +
+
+

+ Your customer might find you through Google today, an AI answer tomorrow, and a paid ad three + days later. +

+

They don’t think in channels. Your growth strategy shouldn’t either.

+
+
+
+

+ AI Growth Technologies brings paid media, search, AI visibility, technology, and attribution + together so each part strengthens the others. +

+

One strategy. Connected execution. A clearer view of what is actually driving growth.

+
+ +
+
+ +
+ + + Scroll + +
+
+
+ + {/* ───────────────────────── Numbers ───────────────────────── */} +
+
+
+
+ The Numbers Behind the Work +

+ Good Growth Leaves Evidence. +

+
+
+
+ {stats.map((s, i) => ( +
+ +
{s.label}
+
+ ))} +
+
+
+ + {/* ───────────────────────── Capabilities ───────────────────────── */} +
+
+
+
+ Everything Works Better Connected +

+ Eight Capabilities. +
+ One Growth Engine. +

+
+
+
+
+

+ A search can lead to an ad. An ad can lead to a website visit. An AI answer can introduce the brand + before either happens. +

+
+
+

To your customer, that is not SEO, paid media, AI, and web.

+

It is one experience with your business.

+

Our capabilities are designed to work that way too.

+
+
+
+ +
+
+
+ + {/* ───────────────────────── Proprietary technology ───────────────────────── */} +
+
+
+
+ Proprietary Technology +

+ We Wanted Better Answers, So We Built Better Tools. +

+
+
+
+
+

Marketing produces plenty of numbers.

+

The harder part is knowing which ones matter.

+
+
+

+ Our proprietary technology gives our teams and clients a clearer view across attribution, organic and + AI visibility, and digital resilience. +

+

+ Not more data for the sake of it. Better information for the decisions that come next. +

+
+
+ +
+ {products.map((p, i) => ( +
+
+ +
+
+
+ {String(i + 1).padStart(2, "0")} / 03 + {p.kind} +
+

+ {p.name} +

+

{p.headline}

+
+ {p.body.map((b) => ( +

{b}

+ ))} +
+ + {p.cta} + +
+
+ ))} +
+
+
+ + {/* ───────────────────────── Discovery ───────────────────────── */} +
+
+
+
+ Discovery Is Changing +

+ Your Next Customer May Ask Before They Search. +

+
+
+ +
+
People now ask AI platforms
+ +
+
+ Google + Rankings + Authority +
+
Your Brand
+
+ ChatGPT + Gemini + AI Overviews + Perplexity +
+
+
+ +
+
+

People still search Google.

+

+ Now they also ask AI platforms which companies to consider, how products compare, what to buy, and + who to trust. +

+
+
+
+

That changes what visibility means.

+

+ Rankings still matter. But so do the information, authority, context, and signals that help AI + systems understand a brand well enough to surface it. +

+

Our SEO and LLMO work connects both sides of that shift.

+
+ + Explore AI Visibility + +
+
+
+
+ + {/* ───────────────────────── Attention to action ───────────────────────── */} +
+
+
+
+ From Attention to Action +

+ The Last Click Rarely Tells the Whole Story. +

+
+
+ +
+
    + {journeySteps.map((s, i) => ( +
  1. + + + {String(i + 1).padStart(2, "0")} — {s.channel} + + {s.label} +
  2. + ))} +
+
+ +
+

+ Which interaction gets the credit? +

+
+

Too often, the answer depends on which dashboard you open.

+

+ We bring media, search, AI visibility, and attribution closer together to answer a more useful + question: +

+
+
+ +
+ The more useful question +
What actually helped move the customer toward a decision?
+
+

Better attribution isn’t about producing a prettier report.

+

+ It is about knowing where the next dollar has the best chance of doing something useful. +

+
+ + Explore Our Approach + +
+
+
+ + {/* ───────────────────────── Connections ───────────────────────── */} +
+
+
+
+ The Difference Is in the Connections +

+ Good Channels Don’t Automatically Make a Good System. +

+
+
+ +
    + {gaps.map((g, i) => ( +
  • + {g} + +
  • + ))} +
+ +
+

+ That is the gap we care about. +

+
+

We look at how discovery, acquisition, experience, data, technology, and security affect one another.

+

Then we strengthen the connections that make the whole system perform better.

+
+
+ +
+
+ No. 01 + No channel fighting for credit. +
+
+ No. 02 + No technology added just because it is new. +
+
+ No. 03 + No dashboard pretending the numbers mean more than they do. +
+
+ +

+ Just a clearer system and a team accountable for making it work. +

+
+
+ + {/* ───────────────────────── CTA ───────────────────────── */} +
+ +
+
+ Ready When You Are +
+

+ Bring Us the Growth Problem You Can’t Quite Pin Down. +

+
+

+ Maybe acquisition is getting more expensive. Maybe organic visibility has stalled. Maybe AI is changing + how customers find you. +

+

Or maybe every dashboard looks healthy, but the business is telling you something different.

+
+
+

You don’t need to diagnose it before the call.

+

Bring us what you’re seeing. We’ll start there.

+
+ +
+
-
+ + {/* ───────────────────────── Footer ───────────────────────── */} +
+
+
+
+ +

+ Paid media, SEO, AI visibility, web, attribution, and technology — connected into one accountable + digital growth system. +

+ +
+
+ {footerColumns.map((col) => ( +
+

{col.title}

+
    + {col.links.map((l) => ( +
  • + {l} +
  • + ))} +
+
+ ))} +
+
+ +
+ © 2026 AI Growth Technologies. All rights reserved. + +
+
+
+ ); } diff --git a/src/components/capabilities.tsx b/src/components/capabilities.tsx new file mode 100644 index 0000000..7c198c1 --- /dev/null +++ b/src/components/capabilities.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; +import { capabilities } from "@/content/home"; + +const pad = (i: number) => String(i + 1).padStart(2, "0"); + +// Eight nodes on a ring around a core — every capability connected to every other. +const SIZE = 360; +const C = SIZE / 2; +const RING = 132; +const nodes = capabilities.map((_, i) => { + const a = (i / capabilities.length) * Math.PI * 2 - Math.PI / 2; + return { x: C + Math.cos(a) * RING, y: C + Math.sin(a) * RING }; +}); + +function EngineMap({ active }: { active: number }) { + return ( + + ); +} + +export default function Capabilities() { + const [active, setActive] = useState(0); + const cap = capabilities[active]; + + return ( +
+
    + {capabilities.map((c, i) => ( +
  • + +
    +

    {c.headline}

    +
    + {c.body.map((p) => ( +

    {p}

    + ))} +
    + + {c.cta} → + +
    +
  • + ))} +
+ +
+ +
+
+ {pad(active)} / {cap.name} +
+

{cap.headline}

+
+ {cap.body.map((p) => ( +

{p}

+ ))} +
+ + {cap.cta} → + +
+
+
+ ); +} diff --git a/src/components/globe-canvas.tsx b/src/components/globe-canvas.tsx new file mode 100644 index 0000000..ebea544 --- /dev/null +++ b/src/components/globe-canvas.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type Vec = [number, number, number]; + +// Market hubs (lat, lon) the arcs travel between. +const HUBS: [number, number][] = [ + [40.7, -74.0], // New York + [25.8, -80.2], // Miami + [18.5, -69.9], // Santo Domingo + [19.4, -99.1], // Mexico City + [4.7, -74.1], // Bogotá + [40.4, -3.7], // Madrid + [51.5, -0.1], // London +]; + +const ROUTES: [number, number][] = [ + [0, 6], + [1, 2], + [2, 4], + [3, 1], + [5, 2], + [6, 5], + [0, 3], + [4, 5], +]; + +function fromLatLon(lat: number, lon: number): Vec { + const phi = (lat * Math.PI) / 180; + const theta = (lon * Math.PI) / 180; + return [Math.cos(phi) * Math.sin(theta), Math.sin(phi), Math.cos(phi) * Math.cos(theta)]; +} + +function slerp(a: Vec, b: Vec, t: number): Vec { + const dot = Math.min(1, Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2])); + const omega = Math.acos(dot); + if (omega < 1e-4) return a; + const s = Math.sin(omega); + const k1 = Math.sin((1 - t) * omega) / s; + const k2 = Math.sin(t * omega) / s; + return [a[0] * k1 + b[0] * k2, a[1] * k1 + b[1] * k2, a[2] * k1 + b[2] * k2]; +} + +type Props = { + /** "hero" sits the globe to the right; "center" centres it behind content. */ + layout?: "hero" | "center"; + className?: string; +}; + +export default function GlobeCanvas({ layout = "hero", className = "hero-canvas" }: Props) { + const ref = useRef(null); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + // Fibonacci sphere + const COUNT = 1500; + const points: Vec[] = []; + const golden = Math.PI * (3 - Math.sqrt(5)); + for (let i = 0; i < COUNT; i++) { + const y = 1 - (i / (COUNT - 1)) * 2; + const r = Math.sqrt(1 - y * y); + const th = golden * i; + points.push([Math.cos(th) * r, y, Math.sin(th) * r]); + } + + const hubs = HUBS.map(([la, lo]) => fromLatLon(la, lo)); + const arcs = ROUTES.map(([a, b], i) => { + const steps = 64; + const pts: Vec[] = []; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const p = slerp(hubs[a], hubs[b], t); + const lift = 1 + Math.sin(Math.PI * t) * 0.22; + pts.push([p[0] * lift, p[1] * lift, p[2] * lift]); + } + return { pts, offset: i * 0.37, speed: 0.11 + (i % 3) * 0.03 }; + }); + + let w = 0; + let h = 0; + let dpr = 1; + let cx = 0; + let cy = 0; + let R = 0; + + const resize = () => { + dpr = Math.min(window.devicePixelRatio || 1, 2); + const rect = canvas.getBoundingClientRect(); + w = rect.width; + h = rect.height; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const mobile = w < 768; + // Size against the viewport, not the (possibly taller) section. + const vh = Math.min(h, window.innerHeight); + if (layout === "center") { + cx = w / 2; + cy = h / 2; + R = Math.min(w, vh) * (mobile ? 0.48 : 0.4); + } else if (mobile) { + cx = w * 0.66; + cy = vh * 0.3; + R = w * 0.6; + } else { + cx = w * 0.72; + cy = vh * 0.54; + R = Math.min(vh * 0.4, w * 0.27); + } + }; + + let rotY = -1.25; // start facing the Americas, drifting toward Europe + const tiltX = 0.42; + let mx = 0; + let my = 0; + let tx = 0; + let ty = 0; + + const project = (v: Vec, ry: number, rx: number) => { + const cosY = Math.cos(ry); + const sinY = Math.sin(ry); + const x1 = v[0] * cosY - v[2] * sinY; + const z1 = v[0] * sinY + v[2] * cosY; + const cosX = Math.cos(rx); + const sinX = Math.sin(rx); + const y2 = v[1] * cosX - z1 * sinX; + const z2 = v[1] * sinX + z1 * cosX; + const persp = 3.2 / (3.2 - z2); + return { x: cx + x1 * R * persp, y: cy - y2 * R * persp, z: z2, s: persp }; + }; + + let last = performance.now(); + let elapsed = 0; + let raf = 0; + let running = true; + + const draw = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + if (!reduced) { + elapsed += dt; + rotY += dt * 0.06; + } + mx += (tx - mx) * 0.04; + my += (ty - my) * 0.04; + const ry = rotY + mx * 0.25; + const rx = tiltX + my * 0.12; + + ctx.clearRect(0, 0, w, h); + + // soft halo + const halo = ctx.createRadialGradient(cx, cy, R * 0.7, cx, cy, R * 1.45); + halo.addColorStop(0, "rgba(255,255,255,0.045)"); + halo.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = halo; + ctx.fillRect(0, 0, w, h); + + // outline + ctx.beginPath(); + ctx.arc(cx, cy, R * 1.0, 0, Math.PI * 2); + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + ctx.stroke(); + + // dots + for (let i = 0; i < points.length; i++) { + const p = project(points[i], ry, rx); + const front = p.z > 0; + const a = front ? 0.18 + p.z * 0.62 : 0.05 + (1 + p.z) * 0.05; + const size = (front ? 1.15 : 0.8) * p.s; + ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`; + ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size); + } + + // arcs + for (const arc of arcs) { + const proj = arc.pts.map((v) => project(v, ry, rx)); + ctx.lineWidth = 1; + for (let s = 1; s < proj.length; s++) { + const a = proj[s - 1]; + const b = proj[s]; + const vis = (a.z + b.z) / 2; + if (vis < -0.15) continue; + ctx.strokeStyle = `rgba(255,255,255,${(0.08 + Math.max(0, vis) * 0.22).toFixed(3)})`; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + // traveling pulse + const t = ((elapsed * arc.speed + arc.offset) % 1) * (proj.length - 1); + const head = Math.floor(t); + const tail = Math.max(0, head - 10); + for (let s = tail + 1; s <= head; s++) { + const a = proj[s - 1]; + const b = proj[s]; + if ((a.z + b.z) / 2 < -0.1) continue; + const k = (s - tail) / (head - tail || 1); + ctx.strokeStyle = `rgba(218,41,28,${(k * 0.95).toFixed(3)})`; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + } + + // hubs + for (let i = 0; i < hubs.length; i++) { + const p = project(hubs[i], ry, rx); + if (p.z < 0) continue; + const pulse = (elapsed * 0.6 + i * 0.21) % 1; + ctx.strokeStyle = `rgba(218,41,28,${((1 - pulse) * 0.8 * p.z).toFixed(3)})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(p.x, p.y, 3 + pulse * 14, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = `rgba(255,255,255,${(0.4 + p.z * 0.6).toFixed(3)})`; + ctx.fillRect(p.x - 2.5, p.y - 2.5, 5, 5); + } + + if (running && !reduced) raf = requestAnimationFrame(draw); + }; + + const onMove = (e: PointerEvent) => { + tx = (e.clientX / window.innerWidth) * 2 - 1; + ty = (e.clientY / window.innerHeight) * 2 - 1; + }; + + resize(); + const ro = new ResizeObserver(() => { + resize(); + if (reduced) draw(performance.now()); + }); + ro.observe(canvas); + + const io = new IntersectionObserver(([entry]) => { + const visible = entry.isIntersecting; + if (visible && !running) { + running = true; + last = performance.now(); + raf = requestAnimationFrame(draw); + } else if (!visible) { + running = false; + cancelAnimationFrame(raf); + } + }); + io.observe(canvas); + + window.addEventListener("pointermove", onMove, { passive: true }); + raf = requestAnimationFrame(draw); + + return () => { + running = false; + cancelAnimationFrame(raf); + ro.disconnect(); + io.disconnect(); + window.removeEventListener("pointermove", onMove); + }; + }, [layout]); + + return