Add site-wide topography patterns and refine section styling.
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run

Use mainwhite.svg on white sections with curvy green transitions into flat green bands, improve text and button contrast, and deploy via OpenNext on Cloudflare Workers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
“kirukib” 2026-05-20 20:34:36 +03:00
parent 1a710aa3c6
commit 3693495dd0
69 changed files with 9897 additions and 757 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@
/out/ /out/
.vercel/ .vercel/
.open-next/ .open-next/
.wrangler/
# production # production

View File

@ -12,11 +12,15 @@ npm run dev
Open [http://localhost:3000](http://localhost:3000). Open [http://localhost:3000](http://localhost:3000).
If you see `ENOENT` errors under `.next/…/app-build-manifest.json` or `_buildManifest.js.tmp`, stop the server, run `rm -rf .next`, then `npm run dev` again. Use `npm run dev:clean` to do both. Turbopack (`npm run dev:turbo`) is opt-in and can be flaky with manifest writes on some setups.
## Scripts ## Scripts
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `npm run dev` | Start dev server (Turbopack) | | `npm run dev` | Start dev server (webpack — stable default) |
| `npm run dev:turbo` | Dev server with Turbopack (faster, optional) |
| `npm run dev:clean` | Remove `.next` cache then start dev (fixes broken dev cache) |
| `npm run build` | Production build | | `npm run build` | Production build |
| `npm run start` | Start production server | | `npm run start` | Start production server |
| `npm run lint` | ESLint | | `npm run lint` | ESLint |
@ -59,9 +63,7 @@ Logos and speaker cutouts are downloaded from the official site via `scripts/dow
## Calendar ## Calendar
- **Add to calendar** dropdown on the hero, tickets section, and payment page - **`.ics` download** at [`/calendar`](app/calendar/route.ts) for Apple Calendar / iCal (direct URL still works if you share it)
- **Google Calendar** / **Outlook** deep links
- **`.ics` download** at [`/calendar`](app/calendar/route.ts) for Apple Calendar / iCal
## Payment API ## Payment API
@ -88,6 +90,8 @@ This project is configured for [**OpenNext on Cloudflare**](https://opennext.js.
npm run deploy npm run deploy
``` ```
`predeploy` automatically removes `.next` and `.open-next` first so each deploy matches a clean local build (avoids stale worker bundles).
Alias: Alias:
```bash ```bash
@ -114,4 +118,24 @@ Add repo secrets:
- OpenNext Cloudflare does **not** support `export const runtime = "edge"`; those exports were removed. - OpenNext Cloudflare does **not** support `export const runtime = "edge"`; those exports were removed.
- Build output is generated under **`.open-next/`** (gitignored). - Build output is generated under **`.open-next/`** (gitignored).
- **`open-next.config.ts`** imports `@opennextjs/cloudflare` — that package **must** be installed locally (`npm install`). Using only `npx @opennextjs/cloudflare` without installing deps will fail with “Could not resolve `@opennextjs/cloudflare`”.
- Use **Node 20 or 22 LTS** for deploys. Node 25 is not recommended for Wrangler / workerd toolchains.
- Local **`next dev`** does not wire OpenNexts miniflare shim unless you set **`OPENNEXT_CLOUDFLARE_DEV=1`** (only needed if you call `getCloudflareContext` in app code during dev). Production deploys are unchanged.
### Troubleshooting
**`ENOENT` / missing `app-build-manifest.json` or `_buildManifest.js.tmp` in dev**
Stop the dev server, delete the cache (`rm -rf .next`), and run `npm run dev` again. Prefer the default dev script (webpack); use `dev:turbo` only if you need Turbopack.
**`ETIMEDOUT` downloading `workerd` or other npm packages**
Retry when the network is stable, or use a VPN / different DNS. You can also raise timeouts:
```bash
npm config set fetch-timeout 600000
npm config set fetch-retries 5
```
Then run `npm install` again before `npm run deploy`.

View File

@ -1,41 +0,0 @@
import { ImageResponse } from "next/og";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#ffffff",
padding: 16,
}}
>
<div
style={{
width: 72,
height: 72,
border: "4px solid #1a5c38",
borderRadius: 12,
marginBottom: 8,
}}
/>
<div style={{ fontSize: 14, fontWeight: 800, color: "#1a5c38", letterSpacing: 1 }}>
GREAT RIFT
</div>
<div style={{ fontSize: 14, fontWeight: 800, color: "#1a5c38", letterSpacing: 1 }}>VALLEY</div>
<div style={{ fontSize: 10, fontWeight: 600, color: "#0d3d26", marginTop: 4 }}>
Innovation Summit
</div>
</div>
),
{ ...size }
);
}

View File

@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { InquiryForm } from "@/components/forms/InquiryForm"; import { InquiryForm } from "@/components/forms/InquiryForm";
import { inquiryChannels } from "@/content/inquiries"; import { inquiryChannels } from "@/content/inquiries";
@ -11,12 +12,18 @@ export const metadata: Metadata = createPageMetadata(pageSeo.contact);
export default function ContactPage() { export default function ContactPage() {
return ( return (
<> <>
<Section className="pt-24"> <PageRiftHeader
<h1 className="text-4xl font-bold">Contact us</h1> variant="minimal"
<p className="mt-4 max-w-2xl text-muted-foreground"> title={<h1 className="text-4xl font-bold">Contact us</h1>}
Reach the right team for registration, exhibitions, sponsorship, or media inquiries. description={
</p> <p>
<div className="mt-10 grid gap-4 sm:grid-cols-2"> Reach the right team for registration, exhibitions, sponsorship, or media inquiries.
</p>
}
/>
<Section>
<div className="grid gap-4 sm:grid-cols-2">
{inquiryChannels.map((ch) => ( {inquiryChannels.map((ch) => (
<Card key={ch.id}> <Card key={ch.id}>
<CardHeader> <CardHeader>
@ -35,6 +42,7 @@ export default function ContactPage() {
))} ))}
</div> </div>
</Section> </Section>
<Section variant="muted"> <Section variant="muted">
<h2 className="text-2xl font-bold">Send a message</h2> <h2 className="text-2xl font-bold">Send a message</h2>
<div className="mt-8 max-w-lg"> <div className="mt-8 max-w-lg">

View File

@ -1,8 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo"; import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo"; import { createPageMetadata } from "@/lib/seo";
import Image from "next/image";
import { exhibitCopy } from "@/content/exhibit"; import { exhibitCopy } from "@/content/exhibit";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { BoothPackages } from "@/components/exhibit/BoothPackages"; import { BoothPackages } from "@/components/exhibit/BoothPackages";
import { ExhibitorBoothForm } from "@/components/exhibit/ExhibitorBoothForm"; import { ExhibitorBoothForm } from "@/components/exhibit/ExhibitorBoothForm";
@ -20,33 +20,21 @@ const benefits = [
export default function ExhibitPage() { export default function ExhibitPage() {
return ( return (
<> <>
<Section className="pt-24"> <PageRiftHeader
<div className="grid gap-10 lg:grid-cols-2"> variant="exhibit"
<div> eyebrow={exhibitCopy.eyebrow}
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]"> title={<h1 className="text-4xl font-bold">{exhibitCopy.headline}</h1>}
{exhibitCopy.eyebrow} description={<p className="leading-relaxed">{exhibitCopy.subheadline}</p>}
</p> >
<h1 className="mt-3 text-4xl font-bold">{exhibitCopy.headline}</h1> <ul className="space-y-3 text-left">
<p className="mt-4 text-muted-foreground leading-relaxed">{exhibitCopy.subheadline}</p> {benefits.map((b) => (
<ul className="mt-6 space-y-3"> <li key={b} className="flex gap-2 text-sm">
{benefits.map((b) => ( <span className="text-[#ffb300]"></span>
<li key={b} className="flex gap-2 text-sm"> {b}
<span className="text-[#ffb300]"></span> </li>
{b} ))}
</li> </ul>
))} </PageRiftHeader>
</ul>
</div>
<div className="relative aspect-video overflow-hidden rounded-2xl">
<Image
src="/branding/booth-mockup.png"
alt="Exhibition booth"
fill
className="object-cover"
/>
</div>
</div>
</Section>
<Section variant="muted"> <Section variant="muted">
<h2 className="text-2xl font-bold">Booth packages</h2> <h2 className="text-2xl font-bold">Booth packages</h2>

View File

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@fontsource-variable/google-sans-flex/wght.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@ -27,6 +28,8 @@
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-body); --font-sans: var(--font-body);
--font-display: var(--font-display); --font-display: var(--font-display);
--font-wordmark: "Google Sans Flex Variable", system-ui, sans-serif;
--font-hero-serif: var(--font-hero-serif);
--color-brand-green: #1a5c38; --color-brand-green: #1a5c38;
--color-brand-green-dark: #0d3d26; --color-brand-green-dark: #0d3d26;
--color-brand-gold: #ffb300; --color-brand-gold: #ffb300;
@ -80,19 +83,77 @@
} }
@layer utilities { @layer utilities {
.font-wordmark {
font-family: var(--font-wordmark);
font-weight: 900;
font-variation-settings: "wght" 900;
letter-spacing: -0.02em;
}
.font-hero-serif {
font-family: var(--font-hero-serif), Georgia, "Times New Roman", serif;
}
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
.section-inverse { .section-inverse {
background: var(--section-inverse); background-color: #1a5c38;
color: #fafafa; color: #fafafa;
} }
.section-muted { .section-green {
background: var(--section-muted); background-color: #1a5c38;
color: #fafafa;
} }
.grain { .grain {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
} }
/* Full-page atmosphere — brand wash drift (behind grain + lines) */
@keyframes backdrop-bloom-a {
0%,
100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(4%, -3%) scale(1.08);
}
}
@keyframes backdrop-bloom-b {
0%,
100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-5%, 4%) scale(1.06);
}
}
.backdrop-bloom-a {
animation: backdrop-bloom-a 32s ease-in-out infinite;
}
.backdrop-bloom-b {
animation: backdrop-bloom-b 38s ease-in-out infinite;
}
/* Seamless horizontal pan — valley / ocean line bands */
@keyframes valley-pan-slow {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.valley-pan-slow {
animation: valley-pan-slow 140s linear infinite;
}
.valley-pan-slower {
animation: valley-pan-slow 220s linear infinite;
}
.valley-pan-medium {
animation: valley-pan-slow 95s linear infinite;
}
.valley-pan-slower-reverse {
animation: valley-pan-slow 220s linear infinite reverse;
}
.marquee { .marquee {
animation: marquee 40s linear infinite; animation: marquee 40s linear infinite;
} }
@ -108,6 +169,14 @@
.marquee { .marquee {
animation: none; animation: none;
} }
.backdrop-bloom-a,
.backdrop-bloom-b,
.valley-pan-slow,
.valley-pan-slower,
.valley-pan-medium,
.valley-pan-slower-reverse {
animation: none;
}
} }
.ticket-notch::before, .ticket-notch::before,
.ticket-notch::after { .ticket-notch::after {
@ -127,6 +196,113 @@
bottom: -0.75rem; bottom: -0.75rem;
} }
/* Admission ticket — side notches + rounded body */
.ticket-admission {
border-radius: 1rem;
--ticket-notch-fill: var(--section-inverse, #1a5c38);
}
.ticket-admission[data-ticket-notch="light"] {
--ticket-notch-fill: #f0f5f2;
}
.ticket-admission::before,
.ticket-admission::after {
content: "";
position: absolute;
top: 50%;
z-index: 2;
width: 1.35rem;
height: 1.35rem;
border-radius: 9999px;
background: var(--ticket-notch-fill);
transform: translateY(-50%);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.ticket-admission::before {
left: -0.675rem;
}
.ticket-admission::after {
right: -0.675rem;
}
/* Rift topography tokens */
:root {
--rift-scroll: 0;
--rift-canvas: #fbfdfb;
--rift-stroke: rgba(26, 92, 56, 0.18);
--rift-stroke-minor: rgba(26, 92, 56, 0.26);
--rift-stroke-inverse: rgba(255, 255, 255, 0.25);
--rift-accent: #ffb300;
--rift-channel: #1a5c38;
}
.rift-profile-partners .rift-contour-minor,
.rift-profile-terrace-dense .rift-terrace-line {
opacity: 1.15;
}
.rift-profile-privacy .rift-contour-path,
.rift-profile-contact .rift-contour-path {
opacity: 0.85;
}
.rift-profile-pitch .rift-channel-inner {
stroke: var(--rift-accent);
}
.rift-profile-payment .rift-channel-outer {
stroke: var(--rift-accent);
}
.rift-ambient-pulse .rift-contour-path {
animation: rift-contour-pulse 5s ease-in-out infinite;
}
.rift-channel-open {
background: radial-gradient(
ellipse 12% 35% at 50% 45%,
rgba(255, 179, 0, 0.08) 0%,
transparent 65%
);
opacity: 0;
}
.rift-hero-intro .rift-channel-open {
animation: rift-channel-open 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
}
.rift-hero-settled .rift-channel-open,
.rift-hero-static .rift-channel-open {
opacity: 1;
}
@keyframes rift-channel-open {
0%,
28% {
opacity: 0;
transform: scaleY(0.2);
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
transform: scaleY(1);
}
}
.rift-line-draw path {
transition:
stroke 0.85s ease,
opacity 0.85s ease,
stroke-dashoffset 0.2s ease-out;
}
.valley-pan-slow {
animation-duration: calc(140s - 55s * var(--rift-scroll, 0));
}
.valley-pan-slower {
animation-duration: calc(220s - 80s * var(--rift-scroll, 0));
}
.valley-pan-medium {
animation-duration: calc(95s - 40s * var(--rift-scroll, 0));
}
.valley-pan-slower-reverse {
animation-duration: calc(220s - 80s * var(--rift-scroll, 0));
}
/* Navbar tickets CTA — glow + text + arrow */ /* Navbar tickets CTA — glow + text + arrow */
@keyframes ticket-glow { @keyframes ticket-glow {
0%, 0%,
@ -188,4 +364,422 @@
animation: none; animation: none;
} }
} }
/* Mobile menu tickets — continuous right-to-left scroll */
@keyframes ticket-menu-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.ticket-menu-marquee {
animation: ticket-menu-marquee 11s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.ticket-menu-marquee {
animation: none;
}
}
/* ─── Hero rift cinematic (10s intro) ─── */
.font-hero-serif,
.rift-hero-heading {
font-family: var(--font-hero-serif), Georgia, "Times New Roman", serif;
}
/* ─── mainwhite.svg on white sections only ─── */
.topo-tone-light .topo-pattern-asset {
mix-blend-mode: multiply;
filter: sepia(0.35) saturate(2.6) hue-rotate(92deg) brightness(0.92) contrast(1.2);
}
.topo-content-layer {
position: relative;
z-index: 10;
}
/* Plain readable copy on white — no glow, shadow, or blur */
.section-white .topo-content-readable,
.section-white .topo-content-readable :is(h1, h2, h3, h4, p, li, label, summary),
.section-white .topo-content-readable a:not([data-slot="button"]) {
color: #0d3d26;
text-shadow: none;
}
.section-white .topo-content-readable .text-muted-foreground {
color: #3d5248;
}
.topo-curvy-extend {
overflow: visible;
}
.topo-prose-surface-light {
background: #ffffff;
border: 1px solid rgba(26, 92, 56, 0.12);
box-shadow: none;
backdrop-filter: none;
}
.topo-prose-surface-green {
border: none;
background: transparent;
box-shadow: none;
backdrop-filter: none;
padding: 0;
}
.topo-prose-surface {
border-radius: 1rem;
padding: 1.25rem 1.5rem;
}
@media (min-width: 768px) {
.topo-prose-surface {
padding: 1.5rem 2rem;
}
}
.topo-card-layer,
[data-slot="card"] {
position: relative;
z-index: 20;
isolation: isolate;
}
.section-green .topo-content-layer,
.section-green .topo-content-layer :is(h1, h2, h3, h4, p, li, a, label, span) {
color: #ffffff;
text-shadow: none;
}
.section-green .text-muted-foreground {
color: rgba(255, 255, 255, 0.88);
}
.section-green [data-slot="button"][data-variant="outline"] {
border-color: rgba(255, 255, 255, 0.75);
background-color: transparent;
color: #ffffff;
box-shadow: none;
}
.section-green [data-slot="button"][data-variant="outline"]:hover {
background-color: rgba(255, 255, 255, 0.14);
color: #ffffff;
}
.section-green [data-slot="button"][data-variant="ghost"] {
color: #ffffff;
}
.section-green [data-slot="button"][data-variant="ghost"]:hover {
background-color: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.section-green [data-slot="button"][data-variant="default"]:not([class*="bg-[#ffb300]"]) {
background-color: #ffb300;
color: #0f0404;
}
.section-green [data-slot="button"][data-variant="default"]:not([class*="bg-[#ffb300]"]):hover {
background-color: #e6a200;
color: #0f0404;
}
.topo-pattern-clearance-section {
-webkit-mask-image: radial-gradient(
ellipse min(92%, 44rem) min(58%, 540px) at 50% 44%,
transparent 0%,
transparent 34%,
rgba(0, 0, 0, 0.18) 50%,
rgba(0, 0, 0, 0.62) 72%,
#000 100%
);
mask-image: radial-gradient(
ellipse min(92%, 44rem) min(58%, 540px) at 50% 44%,
transparent 0%,
transparent 34%,
rgba(0, 0, 0, 0.18) 50%,
rgba(0, 0, 0, 0.62) 72%,
#000 100%
);
}
.topo-pattern-clearance-header {
-webkit-mask-image: radial-gradient(
ellipse min(90%, 40rem) min(48%, 400px) at 50% 58%,
transparent 0%,
transparent 38%,
rgba(0, 0, 0, 0.22) 56%,
rgba(0, 0, 0, 0.68) 78%,
#000 100%
);
mask-image: radial-gradient(
ellipse min(90%, 40rem) min(48%, 400px) at 50% 58%,
transparent 0%,
transparent 38%,
rgba(0, 0, 0, 0.22) 56%,
rgba(0, 0, 0, 0.68) 78%,
#000 100%
);
}
.rift-hero-settled .rift-hero-pattern-site .rift-contour-path,
.rift-hero-settled .rift-hero-pattern-water .rift-contour-path {
animation: rift-contour-pulse 8s ease-in-out infinite;
stroke-dashoffset: 0;
}
.rift-hero-settled .rift-channel-inner {
animation: rift-river-shimmer 6s ease-in-out infinite;
stroke-dashoffset: 0;
}
.rift-crack-line {
background: linear-gradient(
90deg,
transparent,
rgba(255, 179, 0, 0.15) 20%,
rgba(255, 200, 100, 0.95) 50%,
rgba(255, 179, 0, 0.15) 80%,
transparent
);
box-shadow: 0 0 20px rgba(255, 179, 0, 0.6);
transform-origin: center;
opacity: 0;
}
.rift-floor-glow {
background: radial-gradient(
ellipse 40% 18% at 50% 100%,
rgba(255, 179, 0, 0.12) 0%,
rgba(255, 179, 0, 0.04) 40%,
transparent 70%
);
opacity: 0;
}
.rift-plate-left,
.rift-plate-right {
transform-origin: center center;
}
.rift-ridge-draw,
.rift-edge-draw {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.rift-contour-path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
@keyframes rift-hero-crack {
0%,
8% {
opacity: 0;
transform: scaleX(0);
}
18% {
opacity: 0.6;
transform: scaleX(0.08);
}
32% {
opacity: 1;
transform: scaleX(1);
}
100% {
opacity: 0.35;
transform: scaleX(1);
}
}
@keyframes rift-hero-floor {
0%,
25% {
opacity: 0;
transform: scale(0.6);
}
45% {
opacity: 0.7;
}
70%,
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes rift-hero-split-left {
0%,
30% {
transform: translateX(0);
}
65%,
100% {
transform: translateX(-6%);
}
}
@keyframes rift-hero-split-right {
0%,
30% {
transform: translateX(0);
}
65%,
100% {
transform: translateX(6%);
}
}
@keyframes rift-hero-draw {
0%,
20% {
stroke-dashoffset: 1;
opacity: 0;
}
55% {
stroke-dashoffset: 0.15;
opacity: 0.7;
}
100% {
stroke-dashoffset: 0;
opacity: 1;
}
}
@keyframes rift-hero-title-in {
0%,
58% {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
78%,
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes rift-contour-drift {
0%,
100% {
transform: translateX(0) translateY(0);
}
50% {
transform: translateX(6px) translateY(-3px);
}
}
@keyframes rift-contour-pulse {
0%,
100% {
opacity: 0.12;
}
50% {
opacity: 0.28;
}
}
@keyframes rift-river-shimmer {
0%,
100% {
opacity: 0.35;
stroke-width: 1;
}
50% {
opacity: 0.75;
stroke-width: 1.6;
}
}
.rift-hero-intro .rift-floor-glow {
animation: rift-hero-floor 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
}
.rift-hero-intro .rift-plate-left {
animation: rift-hero-split-left 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
}
.rift-hero-intro .rift-plate-right {
animation: rift-hero-split-right 10s cubic-bezier(0.22, 0.2, 0.2, 1) forwards;
}
.rift-hero-intro .rift-ridge-draw,
.rift-hero-intro .rift-edge-draw {
animation: rift-hero-draw 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
}
.rift-hero-intro .rift-contour-path,
.rift-hero-intro .rift-channel-inner {
animation: rift-hero-draw 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
}
.rift-hero-intro .rift-hero-title {
animation: rift-hero-title-in 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
}
.rift-hero-settled .rift-contour-morph {
animation: rift-contour-drift 14s ease-in-out infinite;
}
.rift-hero-settled .rift-contour-path,
.rift-hero-settled .rift-channel-outer,
.rift-hero-settled .rift-channel-inner {
animation: rift-contour-pulse 5s ease-in-out infinite;
stroke-dashoffset: 0;
}
.rift-hero-settled .rift-channel-inner {
animation: rift-river-shimmer 4s ease-in-out infinite;
}
.rift-hero-settled .rift-floor-glow {
opacity: 0.5;
}
.rift-hero-settled .rift-hero-title {
opacity: 1;
}
/* Static final frame — reduced motion */
.rift-hero-static .rift-floor-glow {
opacity: 0.45;
}
.rift-hero-static .rift-contour-path,
.rift-hero-static .rift-channel-outer,
.rift-hero-static .rift-channel-inner {
stroke-dashoffset: 0;
opacity: 1;
}
.rift-hero-static .rift-hero-title {
opacity: 1;
}
/* Site-wide pulsing curved lines */
@keyframes rift-pulse-opacity {
0%,
100% {
opacity: 0.14;
}
50% {
opacity: calc(0.22 + var(--rift-scroll, 0) * 0.35);
}
}
.rift-pulse-animate {
animation: rift-pulse-opacity 4.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rift-hero-intro .rift-floor-glow,
.rift-hero-intro .rift-channel-open,
.rift-hero-intro .rift-contour-path,
.rift-hero-intro .rift-channel-outer,
.rift-hero-intro .rift-channel-inner,
.rift-hero-intro .rift-hero-title,
.rift-hero-settled .rift-contour-morph,
.rift-hero-settled .rift-contour-path,
.rift-hero-settled .rift-channel-inner,
.rift-ambient-pulse .rift-contour-path,
.rift-pulse-animate {
animation: none !important;
}
}
} }

View File

@ -1,54 +0,0 @@
import { ImageResponse } from "next/og";
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#0f0404",
borderRadius: 6,
gap: 2,
}}
>
<div
style={{
width: 20,
height: 20,
border: "2px solid #3d8b5f",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 7,
fontWeight: 800,
color: "#3d8b5f",
}}
>
GRV
</div>
<div
style={{
fontSize: 5,
fontWeight: 700,
color: "#1a5c38",
letterSpacing: 0.5,
textAlign: "center",
lineHeight: 1.1,
}}
>
GREAT RIFT
</div>
</div>
),
{ ...size }
);
}

View File

@ -1,4 +1,4 @@
import { Syne, DM_Sans } from "next/font/google"; import { Syne, DM_Sans, Playfair_Display } from "next/font/google";
import { RiftPageFlow } from "@/components/brand/RiftPageFlow"; import { RiftPageFlow } from "@/components/brand/RiftPageFlow";
import { JsonLd } from "@/components/seo/JsonLd"; import { JsonLd } from "@/components/seo/JsonLd";
import { SiteHeader } from "@/components/layout/SiteHeader"; import { SiteHeader } from "@/components/layout/SiteHeader";
@ -11,6 +11,7 @@ export const metadata = rootMetadata;
const display = Syne({ const display = Syne({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-display", variable: "--font-display",
weight: ["600", "700", "800"],
}); });
const body = DM_Sans({ const body = DM_Sans({
@ -18,13 +19,20 @@ const body = DM_Sans({
variable: "--font-body", variable: "--font-body",
}); });
/** Monumental serif for hero / display moments */
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-hero-serif",
weight: ["600", "700", "800"],
});
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className={`${display.variable} ${body.variable}`}> <html lang="en" className={`${display.variable} ${body.variable} ${playfair.variable}`}>
<body className="min-h-screen flex flex-col"> <body className="min-h-screen flex flex-col">
<JsonLd /> <JsonLd />
<SiteHeader /> <SiteHeader />

View File

@ -9,6 +9,7 @@ import {
supporterSections, supporterSections,
mediaPartnerSections, mediaPartnerSections,
} from "@/content/partners"; } from "@/content/partners";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { PartnerSectionBlock } from "@/components/partners/PartnerSectionBlock"; import { PartnerSectionBlock } from "@/components/partners/PartnerSectionBlock";
import { PartnershipCtaBand } from "@/components/partners/PartnershipCtaBand"; import { PartnershipCtaBand } from "@/components/partners/PartnershipCtaBand";
@ -20,25 +21,21 @@ export const metadata: Metadata = createPageMetadata(pageSeo.partners);
export default function PartnersPage() { export default function PartnersPage() {
return ( return (
<> <>
<Section className="pt-24"> <PageRiftHeader
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]"> variant="partners"
{partnersIntro.eyebrow} eyebrow={partnersIntro.eyebrow}
</p> title={<h1 className="max-w-4xl text-4xl font-bold md:text-5xl">{partnersIntro.headline}</h1>}
<h1 className="mt-3 max-w-4xl text-4xl font-bold md:text-5xl"> description={<p className="text-lg leading-relaxed">{partnersIntro.subheadline}</p>}
{partnersIntro.headline} >
</h1> <div className="flex flex-wrap gap-3">
<p className="mt-4 max-w-3xl text-lg text-muted-foreground leading-relaxed">
{partnersIntro.subheadline}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild> <Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href="#partnership-form">Become a partner</Link> <Link href="#partnership-form">Become a partner</Link>
</Button> </Button>
<ChampionStartupModal /> <ChampionStartupModal />
</div> </div>
</Section> </PageRiftHeader>
<Section variant="muted" riftPattern="whisper"> <Section variant="muted">
<div className="space-y-16"> <div className="space-y-16">
{sponsorSections.map((section, index) => ( {sponsorSections.map((section, index) => (
<PartnerSectionBlock <PartnerSectionBlock
@ -50,7 +47,7 @@ export default function PartnersPage() {
</div> </div>
</Section> </Section>
<Section riftPattern="vein-right"> <Section>
<div className="space-y-16"> <div className="space-y-16">
{exhibitorSections.map((section) => ( {exhibitorSections.map((section) => (
<PartnerSectionBlock key={section.id} section={section} /> <PartnerSectionBlock key={section.id} section={section} />
@ -58,7 +55,7 @@ export default function PartnersPage() {
</div> </div>
</Section> </Section>
<Section variant="muted" riftPattern="arc-bottom"> <Section variant="muted">
<div className="space-y-16"> <div className="space-y-16">
{supporterSections.map((section) => ( {supporterSections.map((section) => (
<PartnerSectionBlock key={section.id} section={section} /> <PartnerSectionBlock key={section.id} section={section} />

View File

@ -2,57 +2,34 @@ import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo"; import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo"; import { createPageMetadata } from "@/lib/seo";
import { Suspense } from "react"; import { Suspense } from "react";
import Link from "next/link";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { ticketTiers } from "@/content/tickets"; import { ticketTiers } from "@/content/tickets";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { PaymentForm } from "@/components/payment/PaymentForm"; import { PaymentForm } from "@/components/payment/PaymentForm";
import { AddToCalendar } from "@/components/event/AddToCalendar"; import { TicketCard } from "@/components/tickets/TicketCard";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = createPageMetadata(pageSeo.payment); export const metadata: Metadata = createPageMetadata(pageSeo.payment);
export default function PaymentPage() { export default function PaymentPage() {
return ( return (
<> <>
<Section className="pt-24"> <PageRiftHeader
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> variant="tickets"
<div> eyebrow={site.dates.label}
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]"> title={<h1 className="text-4xl font-bold">Tickets & payment</h1>}
{site.dates.label} description={
</p> <p>
<h1 className="mt-2 text-4xl font-bold">Tickets & payment</h1> Secure your place at {site.venue.name}, {site.venue.address}. Choose a pass and complete
<p className="mt-3 max-w-2xl text-muted-foreground"> checkout below.
Secure your place at {site.venue.name}, {site.venue.address}. Choose a pass and </p>
complete checkout below. }
</p> />
</div>
<AddToCalendar />
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3"> <Section>
{ticketTiers.map((tier) => ( <div className="grid gap-8 md:grid-cols-3 md:items-stretch">
<Card key={tier.id} className="flex flex-col"> {ticketTiers.map((tier, index) => (
<CardHeader> <TicketCard key={tier.id} tier={tier} index={index} compact />
<CardTitle>{tier.name}</CardTitle>
<CardDescription>{tier.description}</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<p className="text-3xl font-bold text-[#1f3d7e]">${tier.priceUsd}</p>
<p className="text-sm text-muted-foreground">per ticket · USD</p>
<ul className="mt-4 space-y-1 text-sm text-muted-foreground">
{tier.features.slice(0, 3).map((f) => (
<li key={f}>· {f}</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button variant="outline" className="w-full rounded-full" asChild>
<Link href={`/payment?ticket=${tier.id}`}>Select</Link>
</Button>
</CardFooter>
</Card>
))} ))}
</div> </div>
</Section> </Section>

View File

@ -3,9 +3,9 @@ import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo"; import { createPageMetadata } from "@/lib/seo";
import Link from "next/link"; import Link from "next/link";
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AddToCalendar } from "@/components/event/AddToCalendar";
export const metadata: Metadata = createPageMetadata(pageSeo.paymentSuccess); export const metadata: Metadata = createPageMetadata(pageSeo.paymentSuccess);
@ -19,31 +19,36 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
const total = params.total ? `$${params.total} USD` : null; const total = params.total ? `$${params.total} USD` : null;
return ( return (
<Section className="pt-24"> <>
<div className="mx-auto max-w-lg text-center"> <PageRiftHeader
<CheckCircle2 className="mx-auto size-16 text-[#1a5c38]" /> variant="minimal"
<h1 className="mt-6 text-3xl font-bold">Thank you for your order</h1> profilePath="/payment/success"
<p className="mt-3 text-muted-foreground"> title={<h1 className="text-3xl font-bold">Thank you for your order</h1>}
Your registration has been received. Order reference:{" "} />
<span className="font-mono font-medium text-foreground">{orderId}</span> <Section>
{total && ( <div className="mx-auto max-w-lg text-center">
<> <CheckCircle2 className="mx-auto size-16 text-[#1a5c38]" />
{" "} <p className="mt-6 text-muted-foreground">
· Total: <span className="font-medium text-foreground">{total}</span> Your registration has been received. Order reference:{" "}
</> <span className="font-mono font-medium text-foreground">{orderId}</span>
)} {total && (
</p> <>
<p className="mt-2 text-sm text-muted-foreground"> {" "}
A confirmation email will be sent once payment processing is connected. For now, our · Total: <span className="font-medium text-foreground">{total}</span>
team has logged your request. </>
</p> )}
<div className="mt-8 flex flex-wrap justify-center gap-3"> </p>
<AddToCalendar variant="default" /> <p className="mt-2 text-sm text-muted-foreground">
<Button variant="outline" className="rounded-full" asChild> A confirmation email will be sent once payment processing is connected. For now, our team
<Link href="/program">View program</Link> has logged your request.
</Button> </p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Button variant="outline" className="rounded-full" asChild>
<Link href="/program">View program</Link>
</Button>
</div>
</div> </div>
</div> </Section>
</Section> </>
); );
} }

View File

@ -1,9 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { pageSeo } from "@/content/page-seo"; import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo"; import { createPageMetadata } from "@/lib/seo";
import Link from "next/link";
import { pitchCompetition } from "@/content/pitch"; import { pitchCompetition } from "@/content/pitch";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { GrantHeadline } from "@/components/grants/GrantHeadline"; import { GrantHeadline } from "@/components/grants/GrantHeadline";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -19,21 +20,26 @@ export const metadata: Metadata = createPageMetadata(pageSeo.pitch);
export default function PitchCompetitionPage() { export default function PitchCompetitionPage() {
return ( return (
<> <>
<Section className="pt-24"> <PageRiftHeader
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]"> variant="pitch"
Pitch competition eyebrow="Pitch competition"
</p> title={
<h1 className="mt-3 text-4xl font-bold md:text-5xl"> <h1 className="text-4xl font-bold md:text-5xl">
<GrantHeadline /> <GrantHeadline />
</h1> </h1>
<p className="mt-2 text-xl text-[#1f3d7e]">{pitchCompetition.subheadline}</p> }
<p className="mt-6 max-w-3xl text-muted-foreground leading-relaxed"> description={
{pitchCompetition.description} <>
</p> <p className="text-xl text-[#1f3d7e]">{pitchCompetition.subheadline}</p>
<Button className="mt-8 rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild> <p className="mt-4 leading-relaxed">{pitchCompetition.description}</p>
</>
}
>
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href={site.links.pitchApplyUrl}>Apply now</Link> <Link href={site.links.pitchApplyUrl}>Apply now</Link>
</Button> </Button>
</Section> </PageRiftHeader>
<Section variant="muted"> <Section variant="muted">
<h2 className="text-2xl font-bold">Award criteria</h2> <h2 className="text-2xl font-bold">Award criteria</h2>
<ul className="mt-6 space-y-3"> <ul className="mt-6 space-y-3">
@ -45,9 +51,10 @@ export default function PitchCompetitionPage() {
))} ))}
</ul> </ul>
</Section> </Section>
<Section> <Section>
<h2 className="text-2xl font-bold">Timeline</h2> <h2 className="text-2xl font-bold">Timeline</h2>
<Accordion type="single" collapsible className="mt-6 max-w-xl"> <Accordion type="single" collapsible className="relative z-10 mt-6 max-w-xl">
{pitchCompetition.timeline.map((t) => ( {pitchCompetition.timeline.map((t) => (
<AccordionItem key={t.phase} value={t.phase}> <AccordionItem key={t.phase} value={t.phase}>
<AccordionTrigger>{t.phase}</AccordionTrigger> <AccordionTrigger>{t.phase}</AccordionTrigger>

View File

@ -2,7 +2,9 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { privacyPolicy } from "@/content/legal"; import { privacyPolicy } from "@/content/legal";
import { pageSeo } from "@/content/page-seo"; import { pageSeo } from "@/content/page-seo";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { createPageMetadata } from "@/lib/seo"; import { createPageMetadata } from "@/lib/seo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -10,26 +12,35 @@ export const metadata: Metadata = createPageMetadata(pageSeo.privacy);
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<Section className="pt-28"> <>
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]">Legal</p> <PageRiftHeader
<h1 className="mt-3 text-4xl font-bold">{privacyPolicy.title}</h1> variant="minimal"
<p className="mt-2 text-sm text-muted-foreground">Last updated: {privacyPolicy.updated}</p> profilePath="/privacy"
<p className="mt-6 max-w-3xl text-muted-foreground leading-relaxed"> eyebrow="Legal"
{privacyPolicy.intro} title={<h1 className="text-4xl font-bold">{privacyPolicy.title}</h1>}
</p> description={
<div className="mt-12 max-w-3xl space-y-10"> <>
{privacyPolicy.sections.map((section) => ( <p className="text-sm">Last updated: {privacyPolicy.updated}</p>
<section key={section.heading}> <p className="mt-4 leading-relaxed">{privacyPolicy.intro}</p>
<h2 className="text-xl font-semibold text-[#1a5c38]">{section.heading}</h2> </>
<p className="mt-3 text-muted-foreground leading-relaxed">{section.body}</p> }
</section> />
))}
</div> <Section>
<div className="mt-12"> <TopoProseSurface className="max-w-3xl space-y-10">
<Button variant="outline" className="rounded-full" asChild> {privacyPolicy.sections.map((section) => (
<Link href="/contact">Contact us</Link> <section key={section.heading}>
</Button> <h2 className="text-xl font-semibold text-[#1a5c38]">{section.heading}</h2>
</div> <p className="mt-3 text-muted-foreground leading-relaxed">{section.body}</p>
</Section> </section>
))}
</TopoProseSurface>
<div className="mt-12">
<Button variant="outline" className="rounded-full" asChild>
<Link href="/contact">Contact us</Link>
</Button>
</div>
</Section>
</>
); );
} }

View File

@ -1,11 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import { programDays } from "@/content/program"; import { programDays } from "@/content/program";
import { pillars } from "@/content/tracks"; import { pillars } from "@/content/tracks";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -14,17 +16,29 @@ export const metadata: Metadata = createPageMetadata(pageSeo.program);
export default function ProgramPage() { export default function ProgramPage() {
return ( return (
<> <>
<Section className="pt-24"> <PageRiftHeader
<h1 className="text-4xl font-bold">Event program</h1> variant="program"
<p className="mt-4 max-w-2xl text-muted-foreground"> eyebrow="Program"
Two days of workshops, panels, exhibition, and the Great Rift Valley Pitch Competition at title={<h1 className="text-4xl font-bold md:text-5xl">Event program</h1>}
Skylight Hotel, Addis Ababa. description={
</p> <p>
<div className="mt-12 grid gap-8 md:grid-cols-2"> Two days of workshops, panels, exhibition, and the Great Rift Valley Pitch Competition at
Skylight Hotel, Addis Ababa.
</p>
}
/>
<Section>
<div className="grid gap-8 md:grid-cols-2">
{programDays.map((day) => ( {programDays.map((day) => (
<Card key={day.id} className="overflow-hidden"> <Card key={day.id} className="overflow-hidden">
<div className="relative h-48"> <div className="relative h-48">
<Image src={day.image || "/branding/booth-mockup.png"} alt={day.title} fill className="object-cover" /> <Image
src={day.image || "/branding/booth-mockup.png"}
alt={day.title}
fill
className="object-cover"
/>
</div> </div>
<CardHeader> <CardHeader>
<p className="text-xs font-semibold uppercase text-[#ffb300]">{day.date}</p> <p className="text-xs font-semibold uppercase text-[#ffb300]">{day.date}</p>
@ -41,20 +55,23 @@ export default function ProgramPage() {
</Card> </Card>
))} ))}
</div> </div>
<div className="mt-12"> </Section>
<Section variant="muted">
<TopoProseSurface className="inline-block">
<h2 className="text-2xl font-bold">Innovation pillars</h2> <h2 className="text-2xl font-bold">Innovation pillars</h2>
<div className="mt-6 grid gap-4 md:grid-cols-3"> </TopoProseSurface>
{pillars.map((p) => ( <div className="mt-6 grid gap-4 md:grid-cols-3">
<Card key={p.id}> {pillars.map((p) => (
<CardHeader> <Card key={p.id}>
<CardTitle>{p.title}</CardTitle> <CardHeader>
</CardHeader> <CardTitle>{p.title}</CardTitle>
<CardContent> </CardHeader>
<p className="text-sm text-muted-foreground">{p.description}</p> <CardContent>
</CardContent> <p className="text-sm text-muted-foreground">{p.description}</p>
</Card> </CardContent>
))} </Card>
</div> ))}
</div> </div>
<Button className="mt-10 rounded-full bg-[#ffb300] text-[#0f0404]" asChild> <Button className="mt-10 rounded-full bg-[#ffb300] text-[#0f0404]" asChild>
<Link href="/pitch-competition">Pitch competition details</Link> <Link href="/pitch-competition">Pitch competition details</Link>

View File

@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { pageSeo } from "@/content/page-seo"; import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo"; import { createPageMetadata } from "@/lib/seo";
import Link from "next/link";
import { import {
speakers, speakers,
speakerGroupLabels, speakerGroupLabels,
@ -9,6 +9,7 @@ import {
type SpeakerGroup, type SpeakerGroup,
} from "@/content/people"; } from "@/content/people";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { SpeakerCard } from "@/components/speakers/SpeakerCard"; import { SpeakerCard } from "@/components/speakers/SpeakerCard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -27,17 +28,22 @@ export default function SpeakersPage() {
return ( return (
<> <>
<Section className="pt-28"> <PageRiftHeader
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">Lineup</p> variant="lineup"
<h1 className="mt-3 max-w-3xl text-4xl font-bold md:text-5xl lg:text-6xl"> eyebrow="Lineup"
Summit speakers &amp; judges title={
</h1> <h1 className="max-w-3xl text-4xl font-bold md:text-5xl lg:text-6xl">
<p className="mt-4 max-w-2xl text-lg text-muted-foreground"> Summit speakers &amp; judges
{site.dates.label} · {site.venue.name} </h1>
</p> }
</Section> description={
<p className="text-lg">
{site.dates.label} · {site.venue.name}
</p>
}
/>
<Section variant="muted" className="pt-0" riftPattern="vein-right"> <Section variant="muted" className="pt-0">
<div className="space-y-20"> <div className="space-y-20">
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map( {(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
([group, list]) => ( ([group, list]) => (

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo"; import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo"; import { createPageMetadata } from "@/lib/seo";
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { InquiryForm } from "@/components/forms/InquiryForm"; import { InquiryForm } from "@/components/forms/InquiryForm";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -28,17 +29,25 @@ const tiers = [
export default function SponsorPage() { export default function SponsorPage() {
return ( return (
<> <>
<Section className="pt-24"> <PageRiftHeader
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">Sponsor</p> variant="sponsor"
<h1 className="mt-3 max-w-3xl text-4xl font-bold"> eyebrow="Sponsor"
Partner with Ethiopia&apos;s flagship innovation summit title={
</h1> <h1 className="max-w-3xl text-4xl font-bold">
<p className="mt-4 max-w-2xl text-muted-foreground"> Partner with Ethiopia&apos;s flagship innovation summit
Support the Ethiopian Diaspora Trust Fund&apos;s mission to foster tech-enabled innovation. </h1>
Sponsorship connects your organization with investors, founders, and leaders across }
agriculture, healthcare, and education. description={
</p> <p>
<div className="mt-12 grid gap-6 md:grid-cols-3"> Support the Ethiopian Diaspora Trust Fund&apos;s mission to foster tech-enabled
innovation. Sponsorship connects your organization with investors, founders, and leaders
across agriculture, healthcare, and education.
</p>
}
/>
<Section>
<div className="grid gap-6 md:grid-cols-3">
{tiers.map((tier) => ( {tiers.map((tier) => (
<Card key={tier.name}> <Card key={tier.name}>
<CardHeader> <CardHeader>
@ -56,6 +65,7 @@ export default function SponsorPage() {
))} ))}
</div> </div>
</Section> </Section>
<Section variant="muted"> <Section variant="muted">
<h2 className="text-2xl font-bold">Sponsorship inquiry</h2> <h2 className="text-2xl font-bold">Sponsorship inquiry</h2>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">

View File

@ -53,10 +53,10 @@ export function BrandLogo({
: "text-[#0d3d26]/90"; : "text-[#0d3d26]/90";
const primarySize = compact const primarySize = compact
? "text-[11px] sm:text-xs md:text-[13px]" ? "text-[12px] sm:text-[13px] md:text-sm"
: isFooter : isFooter
? "text-sm md:text-base" ? "text-base md:text-lg"
: "text-xs sm:text-sm md:text-base"; : "text-sm sm:text-base md:text-lg";
const secondarySize = compact const secondarySize = compact
? "text-[8px] sm:text-[9px]" ? "text-[8px] sm:text-[9px]"
: "text-[9px] sm:text-[10px]"; : "text-[9px] sm:text-[10px]";
@ -72,7 +72,7 @@ export function BrandLogo({
> >
<span <span
className={cn( className={cn(
"font-bold uppercase tracking-tight whitespace-nowrap", "font-wordmark uppercase whitespace-nowrap",
primaryClass, primaryClass,
primarySize primarySize
)} )}

View File

@ -1,70 +1,59 @@
import { cn } from "@/lib/utils"; "use client";
/** import { useEffect } from "react";
* Continuous valley lines from top to bottom of the page. import { usePathname } from "next/navigation";
* Sits behind content; sections use lighter accents on top. import { getPageTopoPattern } from "@/content/topo-patterns";
*/
function useRiftScroll() {
useEffect(() => {
let ticking = false;
const update = () => {
ticking = false;
const max = Math.max(
1,
document.documentElement.scrollHeight - window.innerHeight
);
document.documentElement.style.setProperty(
"--rift-scroll",
String(window.scrollY / max)
);
};
const onScroll = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
document.documentElement.style.removeProperty("--rift-scroll");
};
}, []);
}
/** Solid page backdrop only — patterns live on each section */
export function RiftPageFlow() { export function RiftPageFlow() {
const pathname = usePathname() ?? "/";
useEffect(() => {
document.documentElement.dataset.topoPattern = getPageTopoPattern(pathname);
return () => {
delete document.documentElement.dataset.topoPattern;
};
}, [pathname]);
useRiftScroll();
return ( return (
<div <div
className="pointer-events-none absolute inset-0 z-0 overflow-hidden" className="pointer-events-none absolute inset-0 z-0 bg-white"
aria-hidden aria-hidden
> />
{/* Primary spine — left */}
<svg
className="absolute left-[4%] top-0 hidden h-full w-16 opacity-[0.14] md:block lg:left-[6%] lg:w-20"
viewBox="0 0 80 2000"
preserveAspectRatio="none"
fill="none"
>
<path
d="M40 0 C22 280, 58 520, 38 780 S18 1180, 42 1520 S62 1780, 40 2000"
stroke="#1a5c38"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M44 0 C60 320, 28 640, 48 960 S32 1340, 46 1680, 42 2000"
stroke="#1a5c38"
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
/>
</svg>
{/* Secondary thread — right (different rhythm) */}
<svg
className="absolute right-[5%] top-0 hidden h-full w-14 opacity-[0.1] lg:block lg:right-[8%] lg:w-16"
viewBox="0 0 60 2000"
preserveAspectRatio="none"
fill="none"
>
<path
d="M30 0 C48 400, 12 800, 34 1200 S50 1600, 28 2000"
stroke="#1a5c38"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M26 120 Q40 600, 22 1000 T30 2000"
stroke="#ffb300"
strokeWidth="0.75"
strokeLinecap="round"
opacity="0.35"
/>
</svg>
{/* Center whisper — only on xl, very faint */}
<svg
className={cn(
"absolute left-1/2 top-0 hidden h-full w-px -translate-x-1/2",
"opacity-[0.06] xl:block"
)}
viewBox="0 0 2 2000"
preserveAspectRatio="none"
>
<line x1="1" y1="0" x2="1" y2="2000" stroke="#1a5c38" strokeWidth="1" />
</svg>
</div>
); );
} }

View File

@ -0,0 +1,74 @@
"use client";
import { useMemo } from "react";
import { mixHex } from "@/lib/rift-colors";
import { cn } from "@/lib/utils";
const GREEN = "#1a5c38";
const GOLD = "#ffb300";
const BLUE = "#1f3d7e";
/** Long curved paths that pulse across the page background */
const PULSE_CURVES = [
"M -80 120 Q 280 80, 520 140 T 1040 100 T 1400 160",
"M -60 280 Q 320 240, 580 300 T 1100 260 T 1500 320",
"M -100 440 Q 260 400, 500 460 T 980 420 T 1380 480",
"M -40 600 Q 300 560, 620 620 T 1080 580 T 1520 640",
"M -120 180 Q 200 220, 480 160 T 920 200 T 1320 140",
"M -80 520 Q 340 480, 640 540 T 1020 500 T 1480 560",
"M -60 720 Q 400 680, 720 740 T 1140 700 T 1600 760",
"M -100 860 Q 280 820, 560 880 T 1000 840 T 1440 900",
] as const;
type Props = {
progress: number;
scrollY: number;
reduceMotion: boolean;
};
export function RiftPulseField({ progress, scrollY, reduceMotion }: Props) {
const t = Math.max(0, Math.min(1, progress));
const wave = 0.5 - 0.5 * Math.cos(t * Math.PI * 2);
const primary = useMemo(() => {
if (t < 0.33) return mixHex(GREEN, GOLD, t / 0.33);
if (t < 0.66) return mixHex(GOLD, BLUE, (t - 0.33) / 0.33);
return mixHex(BLUE, GREEN, (t - 0.66) / 0.34);
}, [t]);
const accent = useMemo(() => mixHex(GOLD, primary, 0.35 + wave * 0.2), [primary, wave]);
const yShift = reduceMotion ? 0 : scrollY * 0.04;
const drawOffset = reduceMotion ? 0 : Math.max(0, 0.92 - progress * 1.1);
return (
<svg
className="rift-pulse-field pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 1280 1000"
preserveAspectRatio="xMidYMid slice"
aria-hidden
style={{
transform: `translate3d(0, ${yShift}px, 0)`,
opacity: 0.35 + progress * 0.4,
}}
>
<g fill="none" strokeLinecap="round">
{PULSE_CURVES.map((d, i) => (
<path
key={i}
pathLength={1}
d={d}
stroke={i % 2 === 0 ? primary : accent}
strokeWidth={0.8 + (i % 3) * 0.2}
className={cn("rift-pulse-line", !reduceMotion && "rift-pulse-animate")}
style={{
animationDelay: `${i * 0.55}s`,
strokeDasharray: 1,
strokeDashoffset: drawOffset,
transition: "stroke 0.85s ease, stroke-dashoffset 0.3s ease-out",
}}
/>
))}
</g>
</svg>
);
}

View File

@ -1,133 +0,0 @@
import type { RiftPattern } from "@/components/brand/rift-patterns";
import { cn } from "@/lib/utils";
type Props = {
pattern: RiftPattern;
inverse?: boolean;
};
/** Light section-local accent — does not compete with page spine */
export function RiftSectionAccent({ pattern, inverse }: Props) {
if (pattern === "none") return null;
const mainStroke = inverse ? "rgba(255,255,255,0.25)" : "rgba(26,92,56,0.2)";
const softStroke = inverse ? "rgba(255,255,255,0.12)" : "rgba(26,92,56,0.1)";
if (pattern === "whisper") {
return (
<div
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-0 mx-auto h-px max-w-4xl",
inverse
? "bg-gradient-to-r from-transparent via-white/25 to-transparent"
: "bg-gradient-to-r from-transparent via-[#1a5c38]/15 to-transparent"
)}
aria-hidden
/>
);
}
if (pattern === "vein-left") {
return (
<svg
className="pointer-events-none absolute bottom-0 left-2 top-12 z-0 w-10 md:left-6 md:w-14"
viewBox="0 0 64 400"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M32 0 C20 80, 44 160, 30 240 S16 320, 34 400"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "vein-right") {
return (
<svg
className="pointer-events-none absolute bottom-0 right-2 top-12 z-0 w-10 md:right-6 md:w-14"
viewBox="0 0 64 400"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M32 0 C44 100, 18 200, 36 300 S48 360, 30 400"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "arc-top") {
return (
<svg
className="pointer-events-none absolute inset-x-0 top-0 z-0 h-12 w-full md:h-16"
viewBox="0 0 1200 64"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M0 48 Q300 8 600 32 T1200 40"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M0 56 Q400 24 800 44 T1200 52"
fill="none"
stroke={softStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "arc-bottom") {
return (
<svg
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-12 w-full md:h-16"
viewBox="0 0 1200 64"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M0 16 Q350 52 700 28 T1200 24"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "fork") {
return (
<svg
className="pointer-events-none absolute left-1/2 top-0 z-0 h-20 w-full max-w-2xl -translate-x-1/2 opacity-90"
viewBox="0 0 600 80"
preserveAspectRatio="xMidYMin meet"
aria-hidden
>
<path
d="M300 0 L300 36 M300 36 Q220 50 140 64 M300 36 Q380 50 460 64"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
return null;
}

View File

@ -0,0 +1,211 @@
"use client";
import type { RiftPageProfile } from "@/content/rift-page-profiles";
import {
CONTOUR_MAJOR,
CONTOUR_MINOR,
RIFT_CHANNEL_INNER,
RIFT_CHANNEL_OUTER_LEFT,
RIFT_CHANNEL_OUTER_RIGHT,
RIFT_VIEWBOX,
contourOpacity,
getChannelTransform,
} from "@/lib/rift-topography-paths";
import { mixHex } from "@/lib/rift-colors";
import { cn } from "@/lib/utils";
const GREEN = "#1a5c38";
const GOLD = "#ffb300";
const BLUE = "#1f3d7e";
type Props = {
variant: "hero" | "ambient" | "header";
profile: RiftPageProfile;
className?: string;
scrollProgress?: number;
scrollY?: number;
reduceMotion?: boolean;
/** hero intro state */
introPhase?: "intro" | "settled" | "static";
};
function strokesFromProgress(p: number, accentMode: RiftPageProfile["accentMode"]) {
const t = Math.max(0, Math.min(1, p));
let primary = GREEN;
let accent = GOLD;
if (accentMode === "gold") {
primary = mixHex(GOLD, GREEN, 0.35);
accent = GOLD;
} else if (accentMode === "mixed") {
if (t < 0.33) {
primary = mixHex(GREEN, GOLD, t / 0.33);
accent = mixHex(GOLD, BLUE, t / 0.33);
} else if (t < 0.66) {
primary = mixHex(GOLD, BLUE, (t - 0.33) / 0.33);
accent = mixHex(BLUE, GREEN, (t - 0.33) / 0.33);
} else {
primary = mixHex(BLUE, GREEN, (t - 0.66) / 0.34);
accent = mixHex(GREEN, GOLD, (t - 0.66) / 0.34);
}
}
return { primary, accent };
}
export function RiftTopographyLayer({
variant,
profile,
className,
scrollProgress = 0,
scrollY = 0,
reduceMotion = false,
introPhase = "static",
}: Props) {
const { primary, accent } = strokesFromProgress(scrollProgress, profile.accentMode);
const drawOffset = reduceMotion ? 0 : Math.max(0, 1 - scrollProgress * 1.12 - 0.06);
const majorOp = contourOpacity(profile.contourDensity, "major");
const minorOp = contourOpacity(profile.contourDensity, "minor");
const channelTransform = getChannelTransform(profile.channelBias);
const parallaxY = reduceMotion ? 0 : scrollY * 0.04;
const isHero = variant === "hero";
const sceneClass = cn(
isHero && "rift-hero-scene",
isHero && introPhase === "intro" && "rift-hero-intro",
isHero && introPhase === "settled" && "rift-hero-settled",
isHero && introPhase === "static" && "rift-hero-static",
!isHero && profile.profileClass,
profile.enablePulse && !reduceMotion && "rift-ambient-pulse"
);
const strokeTransition =
"stroke 0.85s ease, opacity 0.85s ease, stroke-dashoffset 0.25s ease-out";
const drawStyle = {
strokeDasharray: 1,
strokeDashoffset: drawOffset,
transition: strokeTransition,
} as const;
const showMinor =
profile.contourDensity !== "low" || profile.id === "partners";
return (
<div
className={cn("absolute inset-0 overflow-hidden", sceneClass, className)}
style={{
opacity: variant === "ambient" ? profile.ambientOpacity : 1,
transform: variant === "ambient" ? `translate3d(0, ${parallaxY}px, 0)` : undefined,
}}
aria-hidden
>
{isHero && (
<>
<div
className="absolute inset-0 bg-gradient-to-b from-[#fbfdfb] via-white to-[#f0f5f2]"
aria-hidden
/>
<div
className="absolute inset-0 opacity-70"
style={{
background:
"radial-gradient(ellipse 55% 45% at 50% 48%, rgba(255,179,0,0.12) 0%, transparent 70%)",
}}
aria-hidden
/>
<div className="rift-crack-line pointer-events-none absolute left-1/2 top-[46%] z-10 h-px w-[min(88%,680px)] -translate-x-1/2" />
<div className="rift-floor-glow pointer-events-none absolute inset-0" />
<div className="rift-channel-open pointer-events-none absolute inset-0" aria-hidden />
</>
)}
<svg
className="rift-line-draw absolute inset-0 h-full w-full"
viewBox={`0 0 ${RIFT_VIEWBOX.w} ${RIFT_VIEWBOX.h}`}
preserveAspectRatio="xMidYMid slice"
>
<g
className="rift-contour-major"
fill="none"
stroke={primary}
strokeLinecap="round"
style={{ transform: channelTransform, transformOrigin: "center" }}
>
{CONTOUR_MAJOR.map((d, i) => (
<path
key={`m-${i}`}
pathLength={1}
d={d}
strokeWidth={0.7 + (i % 2) * 0.2}
opacity={majorOp}
className="rift-contour-path"
style={{ ...drawStyle, animationDelay: isHero ? `${i * 0.35}s` : undefined }}
/>
))}
</g>
{showMinor && (
<g
className="rift-contour-minor"
fill="none"
stroke={primary}
strokeLinecap="round"
opacity={minorOp * 1.1}
style={{ transform: channelTransform, transformOrigin: "center" }}
>
{CONTOUR_MINOR.map((d, i) => (
<path
key={`n-${i}`}
pathLength={1}
d={d}
strokeWidth={0.45}
className="rift-contour-path rift-terrace-line"
style={drawStyle}
/>
))}
</g>
)}
<g
className="rift-channel-group"
fill="none"
strokeLinecap="round"
style={{ transform: channelTransform, transformOrigin: "center" }}
>
<path
pathLength={1}
d={RIFT_CHANNEL_OUTER_LEFT}
stroke={accent}
strokeWidth={1.4}
className="rift-channel-outer rift-channel-left"
opacity={0.55}
style={drawStyle}
/>
<path
pathLength={1}
d={RIFT_CHANNEL_OUTER_RIGHT}
stroke={accent}
strokeWidth={1.4}
className="rift-channel-outer rift-channel-right"
opacity={0.55}
style={drawStyle}
/>
{RIFT_CHANNEL_INNER.map((d, i) => (
<path
key={`c-${i}`}
pathLength={1}
d={d}
stroke={profile.accentMode === "gold" ? GOLD : primary}
strokeWidth={0.55}
opacity={0.35}
className="rift-channel-inner"
style={{ ...drawStyle, animationDelay: `${0.2 + i * 0.15}s` }}
/>
))}
</g>
</svg>
{isHero && (
<div className="rift-contour-morph pointer-events-none absolute inset-0" aria-hidden />
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
import { cn } from "@/lib/utils";
type Props = {
className?: string;
};
/**
* Curvy contour lines extending from white pattern sections into the green band below.
*/
export function TopoCurvyExtend({ className }: Props) {
return (
<div
className={cn(
"topo-curvy-extend pointer-events-none absolute right-0 bottom-0 left-0 z-[6] h-16 translate-y-[45%] md:h-24 md:translate-y-[40%]",
className
)}
aria-hidden
>
<svg
className="h-full w-full"
viewBox="0 0 1440 96"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-40 52 C 180 8, 420 88, 720 44 S 1180 12, 1480 56"
fill="none"
stroke="#1a5c38"
strokeWidth="1.25"
strokeOpacity="0.42"
strokeLinecap="round"
/>
<path
d="M-20 68 C 260 92, 520 28, 800 72 S 1220 88, 1500 38"
fill="none"
stroke="#1a5c38"
strokeWidth="1"
strokeOpacity="0.32"
strokeLinecap="round"
/>
<path
d="M0 36 C 320 64, 640 16, 960 48 S 1320 76, 1480 28"
fill="none"
stroke="#1a5c38"
strokeWidth="1.5"
strokeOpacity="0.28"
strokeLinecap="round"
/>
<path
d="M80 82 C 400 58, 700 94, 1040 62 S 1280 42, 1460 78"
fill="none"
stroke="#2d7a52"
strokeWidth="0.9"
strokeOpacity="0.38"
strokeLinecap="round"
/>
</svg>
</div>
);
}

View File

@ -0,0 +1,56 @@
import {
getAssetForTone,
MAIN_WHITE_PATTERN_SRC,
type TopoPatternId,
type TopoSectionTone,
resolveTopoPattern,
} from "@/content/topo-patterns";
import { cn } from "@/lib/utils";
export type TopoTextClearance = "section" | "header" | "none";
type Props = {
pattern?: TopoPatternId;
opacity?: number;
tone?: TopoSectionTone;
textClearance?: TopoTextClearance;
className?: string;
};
/** mainwhite.svg on white sections only */
export function TopographicPattern({
pattern = "topo-main",
opacity,
tone = "light",
className,
}: Props) {
const id = resolveTopoPattern(pattern);
if (!id || tone === "green") return null;
const asset = getAssetForTone("light");
const layerOpacity = opacity ?? asset.defaultOpacity;
return (
<div
className={cn(
"topo-pattern-bg topo-tone-light pointer-events-none absolute inset-0 z-0 overflow-hidden",
className
)}
data-topo-tone="light"
data-pattern-src={MAIN_WHITE_PATTERN_SRC}
aria-hidden
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={MAIN_WHITE_PATTERN_SRC}
alt=""
className="topo-pattern-asset block h-full w-full object-cover"
style={{
opacity: layerOpacity,
objectPosition: asset.positions[0],
}}
draggable={false}
/>
</div>
);
}

View File

@ -1,9 +1,2 @@
/** Subtle per-section line treatments — paired with page-level RiftPageFlow */ /** @deprecated Use TopoPatternId from content/topo-patterns — kept for Section prop name */
export type RiftPattern = export type { TopoPatternId as RiftPattern } from "@/content/topo-patterns";
| "none"
| "whisper"
| "arc-top"
| "arc-bottom"
| "vein-left"
| "vein-right"
| "fork";

View File

@ -3,31 +3,35 @@ import { ArrowRight } from "lucide-react";
import { attendCopy, attendPaths } from "@/content/attend"; import { attendCopy, attendPaths } from "@/content/attend";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { ScrollReveal } from "@/components/motion/ScrollReveal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function AttendSummitSection() { export function AttendSummitSection() {
return ( return (
<Section id="attend" variant="muted" riftPattern="vein-left" className="py-14 md:py-20"> <Section id="attend" variant="muted" className="py-14 md:py-20">
<div className="mx-auto max-w-3xl text-center"> <div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]"> <p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
{attendCopy.eyebrow} {attendCopy.eyebrow}
</p> </p>
<h2 className="mt-3 text-3xl font-bold tracking-tight md:text-4xl"> <h2 className="mt-3 text-3xl font-bold tracking-tight text-white md:text-4xl">
{attendCopy.headline} {attendCopy.headline}
</h2> </h2>
<p className="mt-4 text-muted-foreground leading-relaxed">{attendCopy.subheadline}</p> <p className="mt-4 text-white/85 leading-relaxed">{attendCopy.subheadline}</p>
<p className="mt-2 text-sm font-medium text-[#1a5c38]/80"> <p className="mt-2 text-sm font-medium text-white/75">
{site.dates.label} · {site.venue.name} {site.dates.label} · {site.venue.name}
</p> </p>
</div> </div>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4"> <div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{attendPaths.map((path) => { {attendPaths.map((path, i) => {
const Icon = path.icon; const Icon = path.icon;
return ( return (
<article <ScrollReveal
key={path.id} key={path.id}
variant="card"
delay={i * 75}
as="article"
className={cn( className={cn(
"group flex flex-col rounded-2xl border border-border bg-white p-6 shadow-sm", "group flex flex-col rounded-2xl border border-border bg-white p-6 shadow-sm",
"transition-shadow duration-200 hover:border-[#1a5c38]/25 hover:shadow-md" "transition-shadow duration-200 hover:border-[#1a5c38]/25 hover:shadow-md"
@ -50,7 +54,7 @@ export function AttendSummitSection() {
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" /> <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</Link> </Link>
</Button> </Button>
</article> </ScrollReveal>
); );
})} })}
</div> </div>
@ -62,7 +66,11 @@ export function AttendSummitSection() {
> >
<Link href="/payment">Get tickets</Link> <Link href="/payment">Get tickets</Link>
</Button> </Button>
<Button variant="outline" className="rounded-full border-[#1a5c38]/30 text-[#1a5c38]" asChild> <Button
variant="outline"
className="rounded-full border-white/40 bg-transparent text-white hover:bg-white/10 hover:text-white"
asChild
>
<Link href="/contact">Contact the team</Link> <Link href="/contact">Contact the team</Link>
</Button> </Button>
</div> </div>

View File

@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
export function BoothAcquisitionBand() { export function BoothAcquisitionBand() {
return ( return (
<Section id="booth" riftPattern="arc-bottom"> <Section id="booth">
<div className="grid items-center gap-10 lg:grid-cols-2"> <div className="grid items-center gap-10 lg:grid-cols-2">
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl order-2 lg:order-1"> <div className="relative aspect-[4/3] overflow-hidden rounded-2xl order-2 lg:order-1">
<Image <Image

View File

@ -3,21 +3,26 @@ import Link from "next/link";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { experiences } from "@/content/program"; import { experiences } from "@/content/program";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { ScrollReveal } from "@/components/motion/ScrollReveal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function ExperienceCards() { export function ExperienceCards() {
return ( return (
<Section variant="inverse" id="program" className="grain" riftPattern="arc-top"> <Section variant="inverse" id="program">
<h2 className="max-w-3xl text-3xl font-bold md:text-5xl"> <TopoProseSurface tone="green" className="max-w-3xl">
Two days to go deep into what Ethiopia&apos;s innovators need <h2 className="text-3xl font-bold md:text-5xl">
</h2> Two days to go deep into what Ethiopia&apos;s innovators need
<p className="mt-4 max-w-2xl text-white/70"> </h2>
Workshops, exhibition, and Africa&apos;s largest non-dilutive grant pitch event. <p className="mt-4 max-w-2xl text-white/85">
</p> Workshops, exhibition, and Africa&apos;s largest non-dilutive grant pitch event.
</p>
</TopoProseSurface>
<div className="mt-12 grid gap-6 md:grid-cols-3"> <div className="mt-12 grid gap-6 md:grid-cols-3">
{experiences.map((exp, i) => ( {experiences.map((exp, i) => (
<Card key={exp.id} className="overflow-hidden border-0 bg-white text-foreground"> <ScrollReveal key={exp.id} variant="card" delay={i * 90} as="div">
<Card className="overflow-hidden border-0 bg-white text-foreground">
<div className="relative h-48"> <div className="relative h-48">
<Image <Image
src="/branding/booth-mockup.png" src="/branding/booth-mockup.png"
@ -41,6 +46,7 @@ export function ExperienceCards() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
</ScrollReveal>
))} ))}
</div> </div>
</Section> </Section>

View File

@ -1,5 +1,6 @@
import { faqs } from "@/content/faq"; import { faqs } from "@/content/faq";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -9,9 +10,12 @@ import {
export function Faq() { export function Faq() {
return ( return (
<Section id="faq" riftPattern="whisper"> <Section id="faq">
<h2 className="text-center text-3xl font-bold">Frequently asked questions</h2> <TopoProseSurface className="mx-auto max-w-2xl text-center">
<Accordion type="single" collapsible className="mx-auto mt-10 max-w-2xl"> <h2 className="text-3xl font-bold">Frequently asked questions</h2>
</TopoProseSurface>
<TopoProseSurface className="mx-auto mt-10 max-w-2xl">
<Accordion type="single" collapsible>
{faqs.map((faq, i) => ( {faqs.map((faq, i) => (
<AccordionItem key={faq.id} value={faq.id}> <AccordionItem key={faq.id} value={faq.id}>
<AccordionTrigger className="text-left"> <AccordionTrigger className="text-left">
@ -22,6 +26,7 @@ export function Faq() {
</AccordionItem> </AccordionItem>
))} ))}
</Accordion> </Accordion>
</TopoProseSurface>
</Section> </Section>
); );
} }

View File

@ -1,56 +1,107 @@
import Image from "next/image"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { AddToCalendar } from "@/components/event/AddToCalendar";
import { HeroGrantLine } from "@/components/home/HeroGrantLine"; import { HeroGrantLine } from "@/components/home/HeroGrantLine";
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
import { HeroTopographyBackground } from "@/components/home/HeroTopographyBackground";
import { HeroRiftParticles } from "@/components/home/HeroRiftParticles";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
import { ScrollReveal } from "@/components/motion/ScrollReveal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const INTRO_MS = 10000;
export function Hero() { export function Hero() {
const [reduceMotion, setReduceMotion] = useState(false);
const [introPhase, setIntroPhase] = useState<"intro" | "settled" | "static">("intro");
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => {
const reduced = mq.matches;
setReduceMotion(reduced);
if (reduced) setIntroPhase("static");
};
apply();
mq.addEventListener("change", apply);
if (!mq.matches) {
const t = window.setTimeout(() => setIntroPhase("settled"), INTRO_MS);
return () => {
clearTimeout(t);
mq.removeEventListener("change", apply);
};
}
return () => mq.removeEventListener("change", apply);
}, []);
return ( return (
<section className="relative overflow-hidden bg-white pb-0 pt-24 md:pt-28"> <section className="section-white relative isolate min-h-[min(100svh,900px)] overflow-x-hidden bg-white pb-10">
<div className="mx-auto max-w-4xl px-4 text-center md:px-6"> <HeroTopographyBackground introPhase={introPhase} className="absolute inset-0" />
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground md:text-sm"> <TopoCurvyExtend />
{site.dates.label} · {site.venue.address} <HeroRiftParticles
</p> active={!reduceMotion}
<h1 className="mt-6 text-4xl font-bold leading-[1.05] tracking-tight md:text-6xl lg:text-7xl"> className="pointer-events-none absolute inset-0 z-[15]"
Great Rift Valley />
<br />
<span className="text-[#ffb300]">Innovation</span> Summit <TopoSectionProvider tone="light">
</h1> <div className="topo-content-layer topo-content-readable relative z-20 mx-auto flex min-h-[min(100svh,900px)] max-w-4xl flex-col items-center justify-center px-4 py-24 text-center md:px-6">
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"> <TopoProseSurface className="flex w-full flex-col items-center text-center">
{site.tagline} Presented by {site.presentedBy}. <ScrollReveal immediate variant="fade-up" delay={0}>
</p> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[#1a5c38]/80 md:text-sm">
<div className="mt-8 flex flex-wrap items-center justify-center gap-3"> {site.dates.label} · {site.venue.address}
<Button className="rounded-full bg-[#ffb300] px-8 text-[#0f0404] hover:bg-[#ffb300]/90" asChild> </p>
<Link href={site.links.ticketsUrl}> </ScrollReveal>
Register <ArrowRight className="size-4" />
</Link> <ScrollReveal immediate variant="fade-up" delay={200}>
</Button> <h1
<Button variant="outline" className="rounded-full" asChild> className={cn(
<Link href="/pitch-competition">Apply to pitch</Link> "rift-hero-heading rift-hero-title mt-6 font-display text-3xl font-bold leading-[1.05] tracking-tight text-[#0d3d26] sm:text-4xl md:text-5xl lg:text-6xl"
</Button> )}
<AddToCalendar /> >
</div> <span className="font-wordmark block uppercase tracking-wide">
<p className="mt-4 text-sm text-muted-foreground"> Great Rift Valley
<HeroGrantLine /> </span>
</p> <span className="mt-1 block text-[#ffb300]">Innovation Summit</span>
</div> </h1>
<div className="relative mt-12 h-[280px] overflow-hidden md:h-[360px]"> </ScrollReveal>
<div
className="absolute inset-x-0 top-0 h-16 bg-white" <ScrollReveal immediate variant="fade-up" delay={400}>
style={{ <p className="mx-auto mt-6 max-w-2xl text-lg text-[#1a5c38]/80 md:text-xl">
clipPath: "ellipse(55% 100% at 50% 100%)", {site.tagline} Presented by {site.presentedBy}.
}} </p>
/> </ScrollReveal>
<Image
src="/branding/booth-mockup.png" <ScrollReveal immediate variant="fade-up" delay={500}>
alt="Summit exhibition" <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
fill <Button
className="object-cover object-center" className="rounded-full bg-[#ffb300] px-8 text-[#0f0404] hover:bg-[#ffb300]/90"
priority asChild
/> >
<Link href={site.links.pitchApplyUrl}>
Register <ArrowRight className="size-4" />
</Link>
</Button>
<Button
variant="outline"
className="rounded-full border-[#1a5c38]/30 bg-white/80 text-[#1a5c38] backdrop-blur-sm"
asChild
>
<Link href="/payment">Get tickets</Link>
</Button>
</div>
<p className="mt-4 text-sm text-[#1a5c38]/70">
<HeroGrantLine />
</p>
</ScrollReveal>
</TopoProseSurface>
</div> </div>
</TopoSectionProvider>
</section> </section>
); );
} }

View File

@ -0,0 +1,99 @@
"use client";
import { useEffect, useRef } from "react";
const MAX_PARTICLES = 48;
type Particle = {
x: number;
y: number;
vy: number;
vx: number;
size: number;
alpha: number;
};
type Props = {
active: boolean;
className?: string;
};
export function HeroRiftParticles({ active, className }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!active) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let raf = 0;
let particles: Particle[] = [];
const resize = () => {
const parent = canvas.parentElement;
if (!parent) return;
const dpr = Math.min(window.devicePixelRatio ?? 1, 2);
const w = parent.clientWidth;
const h = parent.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.min(MAX_PARTICLES, Math.floor((w * h) / 12000));
particles = Array.from({ length: count }, () => ({
x: w * (0.35 + Math.random() * 0.3),
y: h * (0.45 + Math.random() * 0.35),
vy: -0.15 - Math.random() * 0.35,
vx: (Math.random() - 0.5) * 0.2,
size: 0.6 + Math.random() * 1.4,
alpha: 0.15 + Math.random() * 0.35,
}));
};
const tick = () => {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.y < h * 0.25) {
p.y = h * (0.55 + Math.random() * 0.3);
p.x = w * (0.35 + Math.random() * 0.3);
}
ctx.beginPath();
ctx.fillStyle = `rgba(255, 191, 80, ${p.alpha})`;
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(tick);
};
resize();
window.addEventListener("resize", resize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
};
}, [active]);
if (!active) return null;
return (
<canvas
ref={canvasRef}
className={className}
aria-hidden
/>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import { TopographicPattern } from "@/components/brand/TopographicPattern";
import { SITE_TOPO_PATTERN } from "@/content/topo-patterns";
import { cn } from "@/lib/utils";
type Props = {
introPhase: "intro" | "settled" | "static";
className?: string;
};
export function HeroTopographyBackground({ introPhase, className }: Props) {
return (
<div
className={cn(
"group/topo-section absolute inset-0 isolate overflow-hidden bg-white",
introPhase === "intro" && "rift-hero-intro",
introPhase === "settled" && "rift-hero-settled",
introPhase === "static" && "rift-hero-static",
className
)}
aria-hidden
>
<TopographicPattern
pattern={SITE_TOPO_PATTERN}
tone="light"
className="topo-hero-layer"
/>
</div>
);
}

View File

@ -1,11 +1,12 @@
import Image from "next/image"; import Image from "next/image";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { PurposeGrantText } from "@/components/home/PurposeGrantText"; import { PurposeGrantText } from "@/components/home/PurposeGrantText";
export function PurposeBand() { export function PurposeBand() {
return ( return (
<Section id="about" riftPattern="vein-left"> <Section id="about">
<div className="grid items-center gap-10 lg:grid-cols-2"> <div className="grid items-center gap-10 lg:grid-cols-2">
<div> <TopoProseSurface>
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]"> <p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
About this summit About this summit
</p> </p>
@ -22,8 +23,8 @@ export function PurposeBand() {
inaugural Great Rift Valley Pitch Competition<PurposeGrantText /> Ten companies will inaugural Great Rift Valley Pitch Competition<PurposeGrantText /> Ten companies will
be selected from the most impactful ventures. be selected from the most impactful ventures.
</p> </p>
</div> </TopoProseSurface>
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl"> <div className="relative z-20 aspect-[4/3] overflow-hidden rounded-2xl">
<Image <Image
src="/branding/booth-mockup.png" src="/branding/booth-mockup.png"
alt="Summit exhibition floor" alt="Summit exhibition floor"

View File

@ -7,6 +7,7 @@ import {
} from "@/content/people"; } from "@/content/people";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { SpeakerCard } from "@/components/speakers/SpeakerCard"; import { SpeakerCard } from "@/components/speakers/SpeakerCard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -21,8 +22,8 @@ export function Speakers() {
); );
return ( return (
<Section id="speakers" riftPattern="vein-right"> <Section id="speakers">
<div className="max-w-3xl"> <TopoProseSurface className="max-w-3xl">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]"> <p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
Lineup Lineup
</p> </p>
@ -33,9 +34,9 @@ export function Speakers() {
Keynotes, panelists, judges, and opening speakers {site.dates.label} at{" "} Keynotes, panelists, judges, and opening speakers {site.dates.label} at{" "}
{site.venue.name}. {site.venue.name}.
</p> </p>
</div> </TopoProseSurface>
<div className="mt-16 space-y-16"> <div className="relative z-20 mt-16 space-y-16">
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map( {(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
([group, list]) => ( ([group, list]) => (
<div key={group}> <div key={group}>
@ -48,8 +49,8 @@ export function Speakers() {
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((speaker) => ( {list.map((speaker, i) => (
<SpeakerCard key={speaker.id} speaker={speaker} /> <SpeakerCard key={speaker.id} speaker={speaker} revealDelay={i * 60} />
))} ))}
</div> </div>
</div> </div>

View File

@ -7,16 +7,16 @@ import { Button } from "@/components/ui/button";
export function SponsorTiers() { export function SponsorTiers() {
return ( return (
<Section variant="muted" id="partners" riftPattern="fork"> <Section variant="muted" id="partners">
<h2 className="text-center text-3xl font-bold">Partners & sponsors</h2> <h2 className="text-center text-3xl font-bold text-white">Partners & sponsors</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-muted-foreground"> <p className="mx-auto mt-3 max-w-xl text-center text-white/80">
Logo slots below are open partner with GRV Summit and feature your brand here. Logo slots below are open partner with GRV Summit and feature your brand here.
</p> </p>
<div className="mt-12 space-y-12"> <div className="mt-12 space-y-12">
{partnerTiers.slice(0, 2).map((tier) => ( {partnerTiers.slice(0, 2).map((tier) => (
<div key={tier.id}> <div key={tier.id}>
<Separator className="mb-6" /> <Separator className="mb-6 bg-white/20" />
<h3 className="text-center text-sm font-semibold uppercase tracking-wider text-[#1f3d7e]"> <h3 className="text-center text-sm font-semibold uppercase tracking-wider text-[#ffb300]">
{tier.name} {tier.name}
</h3> </h3>
<div className="mt-6 flex flex-wrap items-center justify-center gap-6"> <div className="mt-6 flex flex-wrap items-center justify-center gap-6">
@ -28,7 +28,7 @@ export function SponsorTiers() {
))} ))}
</div> </div>
<div className="mt-10 text-center"> <div className="mt-10 text-center">
<Button className="rounded-full bg-[#1f3d7e]" asChild> <Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href="/partners">Become a partner</Link> <Link href="/partners">Become a partner</Link>
</Button> </Button>
</div> </div>

View File

@ -1,16 +1,19 @@
import { site } from "@/content/site"; import { site } from "@/content/site";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount"; import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
export function StatsGrid() { export function StatsGrid() {
return ( return (
<Section variant="muted" id="stats" riftPattern="whisper"> <Section variant="muted" id="stats">
<p className="text-center text-xs font-semibold uppercase tracking-widest text-[#1f3d7e]"> <TopoProseSurface className="mx-auto max-w-3xl text-center">
The future starts here <p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
</p> The future starts here
<h2 className="mt-3 text-center text-3xl font-bold md:text-4xl"> </p>
Powering Ethiopia&apos;s innovation leap forward <h2 className="mt-3 text-3xl font-bold text-white md:text-4xl">
</h2> Powering Ethiopia&apos;s innovation leap forward
</h2>
</TopoProseSurface>
<div className="mt-12 grid grid-cols-2 gap-6 md:grid-cols-4"> <div className="mt-12 grid grid-cols-2 gap-6 md:grid-cols-4">
{site.stats.map((stat) => ( {site.stats.map((stat) => (
<div <div

View File

@ -5,21 +5,20 @@ import { Section } from "@/components/layout/Section";
import { TicketCard } from "@/components/tickets/TicketCard"; import { TicketCard } from "@/components/tickets/TicketCard";
export function TicketsBand() { export function TicketsBand() {
return ( return (
<Section variant="inverse" id="register" riftPattern="arc-top"> <Section variant="inverse" id="tickets">
<div className="relative text-center"> <div className="relative text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]"> <p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
Register Tickets
</p> </p>
<h2 className="mt-2 text-3xl font-bold uppercase tracking-tight md:text-4xl"> <h2 className="mt-2 text-3xl font-bold uppercase tracking-tight md:text-4xl">
Get your ticket Get your ticket
</h2> </h2>
<p className="mx-auto mt-3 max-w-lg text-white/70"> <p className="mx-auto mt-3 max-w-lg text-white/70">
Join 500+ attendees at {site.venue.name}, {site.venue.address}. Add the summit to your Join 500+ attendees at {site.venue.name}, {site.venue.address}. Choose a pass below.
calendar when you choose a pass.
</p> </p>
</div> </div>
<div className="relative mx-auto mt-14 grid max-w-5xl gap-8 md:grid-cols-3 md:items-end"> <div className="relative mx-auto mt-14 grid max-w-5xl gap-10 md:grid-cols-3 md:items-stretch md:gap-8">
{ticketTiers.map((tier, index) => ( {ticketTiers.map((tier, index) => (
<TicketCard <TicketCard
key={tier.id} key={tier.id}

View File

@ -13,7 +13,7 @@ export function TopicMarquee() {
<Badge <Badge
key={`${topic}-${i}`} key={`${topic}-${i}`}
variant="secondary" variant="secondary"
className="shrink-0 rounded-full px-4 py-2 text-sm" className="shrink-0 rounded-full border-white/25 bg-white/15 px-4 py-2 text-sm text-white"
> >
{topic} {topic}
</Badge> </Badge>

View File

@ -6,13 +6,17 @@ import Link from "next/link";
export function Venue() { export function Venue() {
return ( return (
<Section id="venue" riftPattern="vein-left"> <Section id="venue" variant="muted">
<div className="grid gap-8 lg:grid-cols-2"> <div className="grid gap-8 lg:grid-cols-2">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-widest text-[#1f3d7e]">The venue</p> <p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">The venue</p>
<h2 className="mt-2 text-3xl font-bold">{site.venue.name}</h2> <h2 className="mt-2 text-3xl font-bold text-white">{site.venue.name}</h2>
<p className="mt-4 text-muted-foreground">{site.venue.address}</p> <p className="mt-4 text-white/85">{site.venue.address}</p>
<Button className="mt-6 rounded-full" variant="outline" asChild> <Button
className="mt-6 rounded-full border-white/40 text-white hover:bg-white/10 hover:text-white"
variant="outline"
asChild
>
<Link href={site.venue.mapsUrl} target="_blank" rel="noopener noreferrer"> <Link href={site.venue.mapsUrl} target="_blank" rel="noopener noreferrer">
Open in Google Maps Open in Google Maps
</Link> </Link>

View File

@ -0,0 +1,171 @@
"use client";
import Link from "next/link";
import { Menu, X } from "lucide-react";
import { useEffect } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { programDays } from "@/content/program";
import { site } from "@/content/site";
import { TicketsMarqueeCta } from "@/components/layout/NavTicketsCta";
import { cn } from "@/lib/utils";
const navLinks = [
{ href: "/speakers", label: "Lineup" },
{ href: "/pitch-competition", label: "Pitch", badge: "Grants" },
{ href: "/partners", label: "Partners" },
{ href: "/exhibit", label: "Exhibit" },
] as const;
type TriggerProps = {
open: boolean;
onToggle: () => void;
};
export function MobileNavTrigger({ open, onToggle }: TriggerProps) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-sm font-semibold transition-colors lg:hidden",
open
? "bg-[#141414] text-white"
: "bg-[#141414] text-white hover:bg-[#1f1f1f]"
)}
aria-expanded={open}
aria-controls="mobile-nav-panel"
aria-label={open ? "Close menu" : "Open menu"}
>
{open ? "Close" : "Menu"}
{open ? <X className="size-4" strokeWidth={2} /> : <Menu className="size-4" strokeWidth={2} />}
</button>
);
}
type DropdownProps = {
open: boolean;
onClose: () => void;
};
export function MobileNavDropdown({ open, onClose }: DropdownProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
if (!open) return null;
return (
<>
<button
type="button"
className="fixed inset-0 top-16 z-40 bg-black/30 lg:hidden"
aria-label="Close menu"
onClick={onClose}
/>
<nav
id="mobile-nav-panel"
className={cn(
"absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-[1.75rem]",
"bg-[#141414] text-white shadow-2xl shadow-black/40",
"animate-in fade-in slide-in-from-top-3 duration-200"
)}
aria-label="Mobile navigation"
>
<div className="divide-y divide-white/10">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="program" className="border-0">
<AccordionTrigger
className={cn(
"px-5 py-5 text-lg font-medium text-white hover:no-underline",
"[&[data-state=open]>svg]:rotate-180",
"[&>svg]:size-5 [&>svg]:text-white/50"
)}
>
Program
</AccordionTrigger>
<AccordionContent className="px-5 pb-4 pt-0">
<ul className="space-y-1 border-t border-white/10 pt-3">
{programDays.map((day) => (
<li key={day.id}>
<Link
href="/program"
onClick={onClose}
className="block rounded-xl px-3 py-3 transition-colors hover:bg-white/5"
>
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/45">
{day.date}
</span>
<span className="mt-0.5 block text-sm font-medium text-white/90">
{day.title}
</span>
</Link>
</li>
))}
<li>
<Link
href="/program"
onClick={onClose}
className="mt-1 block rounded-xl px-3 py-2.5 text-sm font-semibold text-[#ffb300] hover:bg-white/5"
>
View full program
</Link>
</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={onClose}
className="flex items-center justify-between px-5 py-5 text-lg font-medium transition-colors hover:bg-white/5"
>
<span>{link.label}</span>
{"badge" in link && link.badge && (
<span className="rounded-md bg-[#ffb300] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#0f0404]">
{link.badge}
</span>
)}
</Link>
))}
</div>
<div className="flex items-center gap-3 border-t border-white/10 p-4">
<Link
href={site.links.pitchApplyUrl}
onClick={onClose}
className={cn(
"flex h-12 min-w-0 flex-1 items-center justify-center rounded-full",
"bg-white px-4 text-sm font-bold text-[#0f0404] transition-transform active:scale-[0.98]"
)}
>
Apply to pitch
</Link>
<TicketsMarqueeCta
onNavigate={onClose}
className={cn(
"h-12 min-w-[8.5rem] max-w-[9.5rem] shrink-0",
"focus-visible:ring-offset-[#141414]"
)}
/>
</div>
</nav>
</>
);
}

View File

@ -1,30 +1,69 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** One arrow before “Tickets” — avoids the old “→ Tickets →” double-arrow on one side */
export const TICKET_MARQUEE_SEGMENT = "→ Tickets ";
type MarqueeProps = {
className?: string;
onNavigate?: () => void;
/** Visually hidden label for icon-only / marquee control */
"aria-label"?: string;
};
/**
* Gold pill with continuous horizontal marquee used in header (sm+) and mobile nav sheet.
*/
export function TicketsMarqueeCta({
className,
onNavigate,
"aria-label": ariaLabel = "Get tickets",
}: MarqueeProps) {
const repeated = Array.from({ length: 8 }, (_, i) => (
<span key={i} className="shrink-0 px-1 font-bold whitespace-nowrap">
{TICKET_MARQUEE_SEGMENT}
</span>
));
return (
<Link
href={site.links.ticketsUrl}
onClick={onNavigate}
aria-label={ariaLabel}
className={cn(
"relative flex items-center overflow-hidden rounded-full bg-[#ffb300] text-[#0f0404]",
"shadow-md transition-colors hover:bg-[#ffb300]/90",
"[&:hover_.ticket-menu-marquee]:[animation-duration:6s]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ffb300] focus-visible:ring-offset-2",
className
)}
>
<span className="ticket-menu-marquee flex w-max items-center py-2.5">
{repeated}
{repeated}
</span>
</Link>
);
}
type Props = { type Props = {
className?: string; className?: string;
/** Full-width style for mobile sheet */ /** Full-width pill (e.g. stacked in a narrow column) */
fullWidth?: boolean; fullWidth?: boolean;
}; };
/** Header / tablet — same marquee as mobile sheet, sized for the navbar */
export function NavTicketsCta({ className, fullWidth }: Props) { export function NavTicketsCta({ className, fullWidth }: Props) {
return ( return (
<Button <TicketsMarqueeCta
className={cn( className={cn(
"rounded-full bg-[#ffb300] font-semibold text-[#0f0404] hover:bg-[#ffb300]/90", "ticket-cta-pulse shrink-0",
"ticket-cta-pulse", fullWidth
fullWidth ? "w-full" : "px-5 text-sm", ? "h-12 w-full min-w-0 max-w-none justify-center"
: "h-10 min-w-[9.25rem] max-w-[11rem] sm:h-11 sm:min-w-[10rem] sm:max-w-[12rem]",
className className
)} )}
asChild />
>
<Link href={site.links.ticketsUrl}>
<span className="ticket-cta-text">Tickets</span>
<ArrowRight className="ticket-cta-arrow size-4" aria-hidden />
</Link>
</Button>
); );
} }

View File

@ -0,0 +1,68 @@
import type { ReactNode } from "react";
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
import { TopographicPattern } from "@/components/brand/TopographicPattern";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
import { SITE_TOPO_PATTERN } from "@/content/topo-patterns";
import { cn } from "@/lib/utils";
export type PageRiftHeaderVariant =
| "program"
| "lineup"
| "partners"
| "pitch"
| "exhibit"
| "sponsor"
| "tickets"
| "minimal";
type Props = {
variant: PageRiftHeaderVariant;
profilePath?: string;
eyebrow?: string;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
className?: string;
};
export function PageRiftHeader({
variant,
profilePath,
eyebrow,
title,
description,
children,
className,
}: Props) {
void variant;
void profilePath;
return (
<header
className={cn(
"section-white group/topo-section relative isolate min-h-[220px] overflow-x-hidden border-b border-[#1a5c38]/10 bg-white pt-24 pb-10 md:min-h-[260px] md:pb-12",
className
)}
>
<TopographicPattern pattern={SITE_TOPO_PATTERN} tone="light" />
<TopoCurvyExtend />
<TopoSectionProvider tone="light">
<div className="topo-content-layer topo-content-readable relative z-10 mx-auto max-w-6xl px-4 pt-4 md:px-6">
<TopoProseSurface className="max-w-3xl">
{eyebrow && (
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
{eyebrow}
</p>
)}
<div className="mt-3">{title}</div>
{description && (
<div className="mt-4 text-muted-foreground leading-relaxed">{description}</div>
)}
</TopoProseSurface>
{children && <div className="relative z-[15] mt-8">{children}</div>}
</div>
</TopoSectionProvider>
</header>
);
}

View File

@ -1,6 +1,13 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { RiftSectionAccent } from "@/components/brand/RiftSectionAccent"; import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
import type { RiftPattern } from "@/components/brand/rift-patterns"; import { TopographicPattern } from "@/components/brand/TopographicPattern";
import { ScrollReveal } from "@/components/motion/ScrollReveal";
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
import {
SITE_TOPO_PATTERN,
toneFromSectionVariant,
type TopoPatternId,
} from "@/content/topo-patterns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Props = { type Props = {
@ -8,9 +15,7 @@ type Props = {
className?: string; className?: string;
children: ReactNode; children: ReactNode;
variant?: "default" | "muted" | "inverse"; variant?: "default" | "muted" | "inverse";
/** Subtle line accent for this section — page spine handles vertical flow */ riftPattern?: TopoPatternId;
riftPattern?: RiftPattern;
/** @deprecated Use riftPattern instead */
riftFlow?: boolean; riftFlow?: boolean;
}; };
@ -19,24 +24,43 @@ export function Section({
className, className,
children, children,
variant = "default", variant = "default",
riftPattern = "none", riftPattern = SITE_TOPO_PATTERN,
riftFlow, riftFlow,
}: Props) { }: Props) {
const pattern: RiftPattern = riftFlow && riftPattern === "none" ? "whisper" : riftPattern; let pattern: TopoPatternId = riftPattern;
if (riftFlow && pattern === "none") pattern = SITE_TOPO_PATTERN;
const tone = toneFromSectionVariant(variant);
const isGreen = tone === "green";
const showPattern = !isGreen && pattern !== "none";
return ( return (
<section <section
id={id} id={id}
className={cn( className={cn(
"relative py-16 md:py-24", "group/topo-section relative isolate py-16 md:py-24",
variant === "muted" && "section-muted", isGreen && "section-green bg-[#1a5c38] text-white",
variant === "inverse" && "section-inverse", !isGreen && "section-white bg-white text-[#0d3d26]",
pattern !== "none" && "overflow-hidden", showPattern && "overflow-x-hidden",
isGreen && "overflow-hidden",
className className
)} )}
data-section-tone={tone}
> >
<RiftSectionAccent pattern={pattern} inverse={variant === "inverse"} /> {showPattern && (
<div className="relative z-10 mx-auto max-w-6xl px-4 md:px-6">{children}</div> <TopographicPattern pattern={pattern} tone="light" />
)}
{!isGreen && <TopoCurvyExtend />}
<TopoSectionProvider tone={tone}>
<ScrollReveal
variant="section"
className={cn(
"topo-content-layer relative z-10 mx-auto max-w-6xl px-4 md:px-6",
!isGreen && "topo-content-readable"
)}
>
{children}
</ScrollReveal>
</TopoSectionProvider>
</section> </section>
); );
} }

View File

@ -1,5 +1,4 @@
import Link from "next/link"; import Link from "next/link";
import { BrandLogo } from "@/components/brand/BrandLogo";
import { FooterTopographicBand } from "@/components/brand/FooterTopographicBand"; import { FooterTopographicBand } from "@/components/brand/FooterTopographicBand";
import { FooterNewsletter } from "@/components/layout/FooterNewsletter"; import { FooterNewsletter } from "@/components/layout/FooterNewsletter";
import { site } from "@/content/site"; import { site } from "@/content/site";
@ -45,7 +44,7 @@ const footerColumns = [
export function SiteFooter() { export function SiteFooter() {
return ( return (
<footer className="relative mt-24 overflow-hidden bg-[#1a5c38] text-white"> <footer className="relative mt-24 bg-[#1a5c38] text-white">
<FooterTopographicBand /> <FooterTopographicBand />
<div className="relative z-10 -mt-20 px-4 pb-4 md:px-6"> <div className="relative z-10 -mt-20 px-4 pb-4 md:px-6">
@ -53,10 +52,6 @@ export function SiteFooter() {
</div> </div>
<div className="relative z-10 mx-auto max-w-6xl px-4 pb-12 pt-20 md:px-6 md:pt-24"> <div className="relative z-10 mx-auto max-w-6xl px-4 pb-12 pt-20 md:px-6 md:pt-24">
<div className="mb-12 flex justify-center md:justify-start">
<BrandLogo href="/" variant="footer" />
</div>
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
{footerColumns.map((col) => ( {footerColumns.map((col) => (
<div key={col.title}> <div key={col.title}>

View File

@ -1,24 +1,18 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { ChevronDown, Menu } from "lucide-react"; import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { BrandLogo } from "@/components/brand/BrandLogo"; import { BrandLogo } from "@/components/brand/BrandLogo";
import { MobileNavDropdown, MobileNavTrigger } from "@/components/layout/MobileNavSheet";
import { NavTicketsCta } from "@/components/layout/NavTicketsCta"; import { NavTicketsCta } from "@/components/layout/NavTicketsCta";
import { programDays } from "@/content/program"; import { programDays } from "@/content/program";
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const navPills = [ const navPills = [
@ -32,11 +26,14 @@ const pillClass =
"inline-flex items-center gap-1.5 rounded-full bg-[#1a5c38]/10 px-4 py-2 text-sm font-medium text-[#0d3d26] transition-colors hover:bg-[#1a5c38]/15"; "inline-flex items-center gap-1.5 rounded-full bg-[#1a5c38]/10 px-4 py-2 text-sm font-medium text-[#0d3d26] transition-colors hover:bg-[#1a5c38]/15";
export function SiteHeader() { export function SiteHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
<header className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-4"> <header className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-4">
<div className="relative mx-auto max-w-6xl">
<div <div
className={cn( className={cn(
"mx-auto flex max-w-6xl items-center gap-2 rounded-full", "flex items-center gap-2 rounded-full",
"border border-[#1a5c38]/10 bg-white/95 px-2 py-2", "border border-[#1a5c38]/10 bg-white/95 px-2 py-2",
"shadow-lg shadow-[#1a5c38]/10 backdrop-blur-md" "shadow-lg shadow-[#1a5c38]/10 backdrop-blur-md"
)} )}
@ -87,41 +84,14 @@ export function SiteHeader() {
<div className="ml-auto flex items-center gap-2 pr-1"> <div className="ml-auto flex items-center gap-2 pr-1">
<NavTicketsCta className="hidden sm:inline-flex" /> <NavTicketsCta className="hidden sm:inline-flex" />
<MobileNavTrigger
<Sheet> open={mobileOpen}
<SheetTrigger asChild className="lg:hidden"> onToggle={() => setMobileOpen((v) => !v)}
<Button />
variant="ghost"
size="icon"
className="rounded-full text-[#0f0404] hover:bg-[#1f3d7e]/10"
aria-label="Open menu"
>
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px]">
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
<Link href="/program" className="rounded-lg px-3 py-2 hover:bg-muted">
Program
</Link>
{navPills.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded-lg px-3 py-2 hover:bg-muted"
>
{link.label}
</Link>
))}
<NavTicketsCta className="mt-4" fullWidth />
</nav>
</SheetContent>
</Sheet>
</div> </div>
</div> </div>
<MobileNavDropdown open={mobileOpen} onClose={() => setMobileOpen(false)} />
</div>
</header> </header>
); );
} }

View File

@ -0,0 +1,37 @@
"use client";
import type { ElementType, ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TopoSectionTone } from "@/content/topo-patterns";
import { useTopoSectionTone } from "@/components/layout/TopoSectionContext";
type Props = {
children: ReactNode;
className?: string;
tone?: TopoSectionTone;
as?: ElementType;
};
/** Readable backdrop for large text blocks over the topo pattern */
export function TopoProseSurface({
children,
className,
tone,
as: Tag = "div",
}: Props) {
const sectionTone = useTopoSectionTone();
const resolved = tone ?? sectionTone;
return (
<Tag
className={cn(
"topo-prose-surface relative z-[15]",
resolved === "light" && "topo-prose-surface-light",
resolved === "green" && "topo-prose-surface-green",
className
)}
>
{children}
</Tag>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type { TopoSectionTone } from "@/content/topo-patterns";
const TopoSectionContext = createContext<TopoSectionTone>("light");
export function TopoSectionProvider({
tone,
children,
}: {
tone: TopoSectionTone;
children: ReactNode;
}) {
return (
<TopoSectionContext.Provider value={tone}>{children}</TopoSectionContext.Provider>
);
}
export function useTopoSectionTone(): TopoSectionTone {
return useContext(TopoSectionContext);
}

View File

@ -0,0 +1,103 @@
"use client";
import {
useEffect,
useRef,
useState,
type ElementType,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export type ScrollRevealVariant = "hero" | "card" | "fade-up" | "section";
type Props = {
children: ReactNode;
className?: string;
variant?: ScrollRevealVariant;
/** Stagger delay after element enters view (ms) */
delay?: number;
/** Animate on mount (for above-the-fold hero content) */
immediate?: boolean;
as?: ElementType;
};
const hidden: Record<ScrollRevealVariant, string> = {
hero: "opacity-0 translate-y-10 scale-[0.98]",
card: "opacity-0 translate-y-7",
"fade-up": "opacity-0 translate-y-8",
section: "opacity-0 translate-y-14",
};
const shown: Record<ScrollRevealVariant, string> = {
hero: "opacity-100 translate-y-0 scale-100",
card: "opacity-100 translate-y-0",
"fade-up": "opacity-100 translate-y-0",
section: "opacity-100 translate-y-0",
};
const duration: Record<ScrollRevealVariant, string> = {
hero: "duration-[900ms]",
card: "duration-[650ms]",
"fade-up": "duration-700",
section: "duration-[800ms]",
};
export function ScrollReveal({
children,
className,
variant = "fade-up",
delay = 0,
immediate = false,
as: Tag = "div",
}: Props) {
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (immediate) {
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}
const el = ref.current;
if (!el) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
if (mq.matches) {
setVisible(true);
return;
}
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
obs.disconnect();
}
},
{ threshold: 0.1, rootMargin: "0px 0px -6% 0px" }
);
obs.observe(el);
return () => obs.disconnect();
}, [immediate]);
return (
<Tag
ref={ref}
className={cn(
"ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[opacity,transform]",
duration[variant],
"motion-reduce:!translate-y-0 motion-reduce:!scale-100 motion-reduce:!opacity-100 motion-reduce:!transition-none",
visible ? shown[variant] : hidden[variant],
visible ? "transition-[opacity,transform]" : "",
variant === "card" && "topo-card-layer relative z-20 isolate",
className
)}
style={{ transitionDelay: visible ? `${delay}ms` : "0ms" }}
>
{children}
</Tag>
);
}

View File

@ -85,7 +85,7 @@ export function ChampionStartupModal() {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="rounded-full border-[#1f3d7e] text-[#1f3d7e] hover:bg-[#1f3d7e]/5" className="rounded-full border-white/75 bg-transparent text-white hover:bg-white/12 hover:text-white"
> >
<Rocket className="mr-2 size-4" /> <Rocket className="mr-2 size-4" />
{championStartupCopy.title} {championStartupCopy.title}

View File

@ -1,4 +1,6 @@
import { partnershipCta } from "@/content/partners"; import { partnershipCta } from "@/content/partners";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
import { PartnershipInquiryForm } from "@/components/partners/PartnershipInquiryForm"; import { PartnershipInquiryForm } from "@/components/partners/PartnershipInquiryForm";
import { ChampionStartupModal } from "@/components/partners/ChampionStartupModal"; import { ChampionStartupModal } from "@/components/partners/ChampionStartupModal";
@ -6,10 +8,11 @@ export function PartnershipCtaBand() {
return ( return (
<section <section
id="partnership-form" id="partnership-form"
className="relative overflow-hidden bg-gradient-to-b from-[#0f0404] via-[#1f3d7e] to-[#ffb300] py-16 md:py-24" className="group/topo-section section-green relative isolate overflow-hidden bg-[#1a5c38] py-16 md:py-24"
> >
<div className="mx-auto grid max-w-6xl gap-10 px-4 md:grid-cols-2 md:items-center md:gap-12 md:px-6"> <TopoSectionProvider tone="green">
<div className="text-white"> <div className="topo-content-layer relative z-10 mx-auto grid max-w-6xl gap-10 px-4 md:grid-cols-2 md:items-center md:gap-12 md:px-6">
<TopoProseSurface tone="green">
<p className="text-sm font-semibold uppercase tracking-widest text-white/80"> <p className="text-sm font-semibold uppercase tracking-widest text-white/80">
{partnershipCta.eyebrow} {partnershipCta.eyebrow}
</p> </p>
@ -22,9 +25,10 @@ export function PartnershipCtaBand() {
<div className="mt-8"> <div className="mt-8">
<ChampionStartupModal /> <ChampionStartupModal />
</div> </div>
</div> </TopoProseSurface>
<PartnershipInquiryForm /> <PartnershipInquiryForm />
</div> </div>
</TopoSectionProvider>
</section> </section>
); );
} }

View File

@ -1,21 +1,19 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import type { Person } from "@/content/people"; import type { Person } from "@/content/people";
import { cn } from "@/lib/utils"; import { ScrollReveal } from "@/components/motion/ScrollReveal";
type Props = { type Props = {
speaker: Person; speaker: Person;
className?: string; className?: string;
revealDelay?: number;
}; };
export function SpeakerCard({ speaker, className }: Props) { export function SpeakerCard({ speaker, className, revealDelay = 0 }: Props) {
return ( return (
<ScrollReveal variant="card" delay={revealDelay} as="div" className={className}>
<Link <Link
href="/speakers" href="/speakers"
className={cn( className="topo-card-layer group relative z-20 isolate block overflow-hidden rounded-2xl border border-border bg-white p-4 shadow-sm transition-all hover:border-[#1a5c38]/25 hover:shadow-md"
"group block overflow-hidden rounded-2xl border border-border bg-white p-4 transition-all hover:border-[#1a5c38]/25 hover:shadow-md",
className
)}
> >
<div className="relative mx-auto aspect-[4/5] w-full max-h-[220px] overflow-hidden rounded-xl bg-muted/30"> <div className="relative mx-auto aspect-[4/5] w-full max-h-[220px] overflow-hidden rounded-xl bg-muted/30">
<Image <Image
@ -35,5 +33,6 @@ export function SpeakerCard({ speaker, className }: Props) {
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{speaker.panel}</p> <p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{speaker.panel}</p>
)} )}
</Link> </Link>
</ScrollReveal>
); );
} }

View File

@ -1,11 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { ArrowRight } from "lucide-react"; import { ArrowRight, Ticket } from "lucide-react";
import type { TicketTier } from "@/content/tickets"; import type { TicketTier } from "@/content/tickets";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { AddToCalendar } from "@/components/event/AddToCalendar"; import { TicketInclusionsPopover } from "@/components/tickets/TicketInclusionsPopover";
import { RiftCardConnector } from "@/components/brand/RiftFlowLines"; import { ScrollReveal } from "@/components/motion/ScrollReveal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -13,75 +13,118 @@ type Props = {
tier: TicketTier; tier: TicketTier;
index: number; index: number;
featured?: boolean; featured?: boolean;
/** Lighter layout for /payment tier picker */
compact?: boolean;
}; };
export function TicketCard({ tier, index, featured }: Props) { /** One-line summary for the card face (not the popover) */
function ticketTagline(tier: TicketTier): string {
return tier.features[0] ?? tier.description;
}
export function TicketCard({ tier, index, featured, compact }: Props) {
const price = const price =
tier.priceLabel ?? (tier.priceUsd === 0 ? "Free" : `$${tier.priceUsd}`); tier.priceLabel ?? (tier.priceUsd === 0 ? "Free" : `$${tier.priceUsd}`);
const serial = `GRV-${tier.id.slice(0, 3).toUpperCase()}-${1000 + index}`;
const schedule = tier.scheduleLabel ?? site.dates.label;
return ( return (
<article <ScrollReveal
variant="card"
delay={index * 100}
as="article"
className={cn( className={cn(
"ticket-card-enter flex flex-col", "flex w-full",
featured && "md:-mt-2 md:mb-2" featured && !compact && "md:-mt-3 md:mb-1 md:scale-[1.02]"
)} )}
style={{ animationDelay: `${index * 120}ms` }}
> >
<div <div
className={cn( className={cn(
"relative rounded-3xl border border-white/10 bg-white p-6 shadow-xl", "ticket-admission relative w-full overflow-hidden bg-white text-[#0f0404]",
featured && "ring-2 ring-[#ffb300]/40" "shadow-[0_12px_40px_rgba(0,0,0,0.18)]",
featured && "ring-2 ring-[#ffb300]/50"
)} )}
data-ticket-notch={compact ? "light" : "inverse"}
> >
{featured && ( {featured && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-[#ffb300] px-3 py-0.5 text-xs font-bold uppercase tracking-wider text-[#0f0404]"> <span className="absolute right-3 top-3 z-10 rounded-full bg-[#ffb300] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#0f0404]">
Popular Popular
</span> </span>
)} )}
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{tier.scheduleLabel ?? site.dates.label} <div
</p> className={cn(
<h3 className="mt-2 text-2xl font-bold text-[#0f0404]">{tier.name}</h3> "flex flex-col md:flex-row",
<p className="mt-2 text-sm text-muted-foreground leading-relaxed"> featured && "bg-gradient-to-br from-white via-white to-[#fff9eb]"
{tier.description} )}
</p> >
<p className="mt-4 text-3xl font-bold text-[#1f3d7e]">{price}</p> {/* Stub — price & date only */}
<div className="mt-6 flex flex-col gap-2"> <div
<Button
className={cn( className={cn(
"w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90", "relative flex shrink-0 md:w-[30%]",
featured && "ticket-cta-pulse" "border-b border-dashed border-[#1a5c38]/20 md:border-b-0 md:border-r"
)} )}
disabled={tier.soldOut}
asChild={!tier.soldOut}
> >
{tier.soldOut ? ( <div className="flex w-full flex-row items-center justify-between gap-3 p-4 md:flex-col md:items-stretch md:justify-between md:py-5 md:pl-5 md:pr-4">
<span>Sold out</span> <div className="flex items-center gap-2 text-[#1a5c38] md:flex-col md:items-start">
) : ( <Ticket className="size-4 shrink-0" strokeWidth={2} aria-hidden />
<Link href="/payment"> <span className="text-[10px] font-bold uppercase tracking-[0.18em]">
Get tickets <ArrowRight className="size-4" /> Admit one
</Link> </span>
)} </div>
</Button> <div className="text-right md:text-left">
<AddToCalendar variant="outline" className="w-full border-[#1f3d7e]/20" /> <p
className={cn(
"font-bold tabular-nums leading-none text-[#1f3d7e]",
compact ? "text-2xl" : "text-3xl"
)}
>
{price}
</p>
<p className="mt-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{schedule}
</p>
</div>
</div>
</div>
{/* Main — name, one line, details popover, CTA */}
<div className="flex flex-1 flex-col justify-between gap-4 p-4 md:p-5">
<div className="min-w-0 pr-8 md:pr-0">
<h3 className="text-lg font-bold leading-tight md:text-xl">{tier.name}</h3>
<p className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">
{ticketTagline(tier)}
</p>
</div>
<div className="flex flex-col gap-3">
<TicketInclusionsPopover tier={tier} serial={serial} />
<Button
className={cn(
"w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90",
featured && "ticket-cta-pulse"
)}
disabled={tier.soldOut}
asChild={!tier.soldOut}
>
{tier.soldOut ? (
<span>Sold out</span>
) : (
<Link href={compact ? `/payment?ticket=${tier.id}` : "/payment"}>
{compact ? "Select" : "Get tickets"}
<ArrowRight className="size-4" />
</Link>
)}
</Button>
</div>
</div>
</div> </div>
</div>
<RiftCardConnector /> <div
className="h-1 w-full bg-gradient-to-r from-[#1a5c38] via-[#ffb300] to-[#1f3d7e]"
<div className="rounded-3xl border border-white/10 bg-white/95 p-6 shadow-lg backdrop-blur-sm"> aria-hidden
<p className="text-xs font-semibold uppercase tracking-wider text-[#1f3d7e]"> />
Included
</p>
<ul className="mt-4 space-y-2.5">
{tier.features.map((feature) => (
<li key={feature} className="flex gap-2 text-sm text-[#0f0404]/80">
<span className="text-[#ffb300]"></span>
{feature}
</li>
))}
</ul>
</div> </div>
</article> </ScrollReveal>
); );
} }

View File

@ -0,0 +1,76 @@
"use client";
import { Info } from "lucide-react";
import type { TicketTier } from "@/content/tickets";
import { site } from "@/content/site";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Props = {
tier: TicketTier;
serial: string;
className?: string;
};
export function TicketInclusionsPopover({ tier, serial, className }: Props) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1.5 rounded-full px-3 text-xs font-medium text-[#1a5c38] hover:bg-[#1a5c38]/8",
className
)}
aria-label={`What's included in ${tier.name}`}
>
<Info className="size-3.5 shrink-0" aria-hidden />
What&apos;s included
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(calc(100vw-2rem),20rem)] border-[#1a5c38]/15 p-0"
>
<div className="border-b border-border/80 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-[#1a5c38]">
{tier.name}
</p>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{tier.description}
</p>
</div>
<ul className="space-y-2 px-4 py-3">
{tier.features.map((feature) => (
<li key={feature} className="flex gap-2 text-sm text-foreground">
<span className="font-bold text-[#ffb300]" aria-hidden>
</span>
{feature}
</li>
))}
</ul>
<div className="space-y-1 border-t border-border/80 bg-muted/40 px-4 py-3 text-xs text-muted-foreground">
<p>
<span className="font-medium text-foreground">When:</span>{" "}
{tier.scheduleLabel ?? site.dates.label}
</p>
<p>
<span className="font-medium text-foreground">Where:</span>{" "}
{site.venue.name}, {site.venue.address}
</p>
<p className="font-mono text-[10px] uppercase tracking-wider opacity-70">
{serial}
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", "topo-card-layer relative z-20 isolate flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className className
)} )}
{...props} {...props}

45
components/ui/popover.tsx Normal file
View File

@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 8,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg outline-none",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -45,6 +45,6 @@ export const faqs: FaqItem[] = [
id: "register", id: "register",
question: "How do I register to attend?", question: "How do I register to attend?",
answer: answer:
"Registration links will be published when the next edition opens. Use the Register button in the navigation or subscribe to updates via the newsletter form.", "Use Tickets in the navigation (or the ticket cards on the home page) to purchase a pass. Use the Register button on the hero to apply for the pitch competition.",
}, },
]; ];

View File

@ -0,0 +1,162 @@
export type RiftPageProfileId =
| "home"
| "program"
| "speakers"
| "partners"
| "pitch"
| "exhibit"
| "sponsor"
| "contact"
| "payment"
| "payment-success"
| "privacy";
export type ChannelBias = "center" | "left" | "right";
export type ContourDensity = "low" | "medium" | "high";
export type AccentMode = "green" | "gold" | "mixed";
export type RiftPageProfile = {
id: RiftPageProfileId;
ambientOpacity: number;
contourDensity: ContourDensity;
channelBias: ChannelBias;
accentMode: AccentMode;
enablePulse: boolean;
enableIntro: boolean;
/** CSS class modifier on ambient layer */
profileClass: string;
};
const PROFILES: Record<RiftPageProfileId, RiftPageProfile> = {
home: {
id: "home",
ambientOpacity: 0.55,
contourDensity: "high",
channelBias: "center",
accentMode: "mixed",
enablePulse: true,
enableIntro: true,
profileClass: "rift-profile-home",
},
program: {
id: "program",
ambientOpacity: 0.7,
contourDensity: "medium",
channelBias: "center",
accentMode: "green",
enablePulse: true,
enableIntro: false,
profileClass: "rift-profile-program",
},
speakers: {
id: "speakers",
ambientOpacity: 0.75,
contourDensity: "medium",
channelBias: "right",
accentMode: "green",
enablePulse: true,
enableIntro: false,
profileClass: "rift-profile-speakers",
},
partners: {
id: "partners",
ambientOpacity: 0.8,
contourDensity: "high",
channelBias: "right",
accentMode: "green",
enablePulse: true,
enableIntro: false,
profileClass: "rift-profile-partners",
},
pitch: {
id: "pitch",
ambientOpacity: 0.75,
contourDensity: "medium",
channelBias: "center",
accentMode: "gold",
enablePulse: true,
enableIntro: false,
profileClass: "rift-profile-pitch",
},
exhibit: {
id: "exhibit",
ambientOpacity: 0.72,
contourDensity: "medium",
channelBias: "center",
accentMode: "mixed",
enablePulse: true,
enableIntro: false,
profileClass: "rift-profile-exhibit",
},
sponsor: {
id: "sponsor",
ambientOpacity: 0.65,
contourDensity: "medium",
channelBias: "center",
accentMode: "green",
enablePulse: false,
enableIntro: false,
profileClass: "rift-profile-sponsor",
},
contact: {
id: "contact",
ambientOpacity: 0.4,
contourDensity: "low",
channelBias: "center",
accentMode: "green",
enablePulse: false,
enableIntro: false,
profileClass: "rift-profile-contact",
},
payment: {
id: "payment",
ambientOpacity: 0.7,
contourDensity: "medium",
channelBias: "center",
accentMode: "gold",
enablePulse: true,
enableIntro: false,
profileClass: "rift-profile-payment",
},
"payment-success": {
id: "payment-success",
ambientOpacity: 0.45,
contourDensity: "low",
channelBias: "center",
accentMode: "green",
enablePulse: false,
enableIntro: false,
profileClass: "rift-profile-payment-success",
},
privacy: {
id: "privacy",
ambientOpacity: 0.35,
contourDensity: "low",
channelBias: "center",
accentMode: "green",
enablePulse: false,
enableIntro: false,
profileClass: "rift-profile-privacy",
},
};
export const HOME_PROFILE = PROFILES.home;
export function pathnameToProfileId(pathname: string): RiftPageProfileId {
if (pathname === "/") return "home";
if (pathname.startsWith("/program")) return "program";
if (pathname.startsWith("/speakers")) return "speakers";
if (pathname.startsWith("/partners")) return "partners";
if (pathname.startsWith("/pitch-competition")) return "pitch";
if (pathname.startsWith("/exhibit")) return "exhibit";
if (pathname.startsWith("/sponsor")) return "sponsor";
if (pathname.startsWith("/contact")) return "contact";
if (pathname === "/payment/success") return "payment-success";
if (pathname.startsWith("/payment")) return "payment";
if (pathname.startsWith("/privacy")) return "privacy";
return "home";
}
export function getRiftProfile(pathname: string): RiftPageProfile {
return PROFILES[pathnameToProfileId(pathname)];
}

86
content/topo-patterns.ts Normal file
View File

@ -0,0 +1,86 @@
/**
* Site patterns: mainwhite.svg (white sections) · main.svg (green edge frames)
*/
export type TopoPatternId = "topo-main" | "none";
/** White sections — full mainwhite pattern */
export type TopoSectionTone = "light" | "green";
export const SITE_TOPO_PATTERN = "topo-main" as const satisfies TopoPatternId;
export const MAIN_WHITE_PATTERN_SRC = "/patterns/mainwhite.svg";
export const MAIN_GREEN_PATTERN_SRC = "/patterns/main.svg";
export const BRAND_GREEN = "#1a5c38";
export type TopoPatternAsset = {
src: string;
positions: [string, string];
defaultOpacity: number;
};
export const MAIN_WHITE_ASSET: TopoPatternAsset = {
src: MAIN_WHITE_PATTERN_SRC,
positions: ["50% 46%", "42% 58%"],
defaultOpacity: 0.42,
};
export const MAIN_GREEN_EDGE_ASSET: TopoPatternAsset = {
src: MAIN_GREEN_PATTERN_SRC,
positions: ["left center", "right center"],
defaultOpacity: 0.58,
};
export function getAssetForTone(tone: TopoSectionTone): TopoPatternAsset {
return tone === "light" ? MAIN_WHITE_ASSET : MAIN_GREEN_EDGE_ASSET;
}
export function toneUsesEdgeLayout(tone: TopoSectionTone): boolean {
return tone === "green";
}
export const PAGE_TOPO_PATTERN: Record<string, TopoPatternId> = {
home: SITE_TOPO_PATTERN,
program: SITE_TOPO_PATTERN,
speakers: SITE_TOPO_PATTERN,
partners: SITE_TOPO_PATTERN,
pitch: SITE_TOPO_PATTERN,
exhibit: SITE_TOPO_PATTERN,
sponsor: SITE_TOPO_PATTERN,
contact: SITE_TOPO_PATTERN,
payment: SITE_TOPO_PATTERN,
"payment-success": SITE_TOPO_PATTERN,
privacy: SITE_TOPO_PATTERN,
};
export function getPageTopoPattern(pathname: string): TopoPatternId {
if (pathname === "/") return PAGE_TOPO_PATTERN.home;
if (pathname.startsWith("/program")) return PAGE_TOPO_PATTERN.program;
if (pathname.startsWith("/speakers")) return PAGE_TOPO_PATTERN.speakers;
if (pathname.startsWith("/partners")) return PAGE_TOPO_PATTERN.partners;
if (pathname.startsWith("/pitch-competition")) return PAGE_TOPO_PATTERN.pitch;
if (pathname.startsWith("/exhibit")) return PAGE_TOPO_PATTERN.exhibit;
if (pathname.startsWith("/sponsor")) return PAGE_TOPO_PATTERN.sponsor;
if (pathname.startsWith("/contact")) return PAGE_TOPO_PATTERN.contact;
if (pathname === "/payment/success") return PAGE_TOPO_PATTERN["payment-success"];
if (pathname.startsWith("/payment")) return PAGE_TOPO_PATTERN.payment;
if (pathname.startsWith("/privacy")) return PAGE_TOPO_PATTERN.privacy;
return SITE_TOPO_PATTERN;
}
export function resolveTopoPattern(
pattern: TopoPatternId | undefined,
fallback: TopoPatternId = SITE_TOPO_PATTERN
): Exclude<TopoPatternId, "none"> | null {
const resolved = pattern && pattern !== "none" ? pattern : fallback;
if (resolved === "none") return null;
return "topo-main";
}
/** Map section variant → pattern tone (white = light, muted/inverse = green) */
export function toneFromSectionVariant(
variant: "default" | "muted" | "inverse"
): TopoSectionTone {
return variant === "default" ? "light" : "green";
}

28
lib/rift-colors.ts Normal file
View File

@ -0,0 +1,28 @@
/** Parse #RRGGBB to RGB components */
function hexToRgb(hex: string): [number, number, number] {
const n = hex.replace("#", "");
if (n.length !== 6) return [0, 0, 0];
return [
Number.parseInt(n.slice(0, 2), 16),
Number.parseInt(n.slice(2, 4), 16),
Number.parseInt(n.slice(4, 6), 16),
];
}
function rgbToHex(r: number, g: number, b: number): string {
const c = (x: number) =>
Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0");
return `#${c(r)}${c(g)}${c(b)}`;
}
/** Linear mix between two hex colors (0 = a, 1 = b). */
export function mixHex(a: string, b: string, t: number): string {
const u = Math.max(0, Math.min(1, t));
const [ar, ag, ab] = hexToRgb(a);
const [br, bg, bb] = hexToRgb(b);
return rgbToHex(
ar + (br - ar) * u,
ag + (bg - ag) * u,
ab + (bb - ab) * u
);
}

View File

@ -0,0 +1,70 @@
import type { ChannelBias } from "@/content/rift-page-profiles";
export const RIFT_VIEWBOX = { w: 800, h: 1200 } as const;
/** Outer bounds of central meandering rift (vertical flow) */
export const RIFT_CHANNEL_OUTER_LEFT =
"M 380 1180 C 360 1020, 340 880, 320 760 S 300 620, 310 480 S 330 340, 350 200 S 370 80, 400 40";
export const RIFT_CHANNEL_OUTER_RIGHT =
"M 420 1180 C 440 1020, 460 880, 480 760 S 500 620, 490 480 S 470 340, 450 200 S 430 80, 400 40";
/** Inner tributary / erosion lines inside channel */
export const RIFT_CHANNEL_INNER = [
"M 400 1150 C 395 980, 405 820, 398 680 S 402 520, 408 360 S 404 180, 400 60",
"M 400 1100 C 388 920, 412 740, 395 580 S 410 420, 402 260 S 406 100, 400 50",
"M 400 1050 C 402 880, 396 710, 400 550 S 398 390, 404 230 S 400 80, 400 45",
"M 400 1000 Q 385 800, 415 600 T 400 200",
] as const;
/** Smooth major contours — widely spaced */
export const CONTOUR_MAJOR = [
"M 40 200 C 120 180, 200 220, 280 190 S 400 160, 520 200 S 640 170, 760 210",
"M 60 320 C 180 300, 300 340, 420 310 S 540 280, 660 320 S 740 290, 760 310",
"M 30 440 C 160 420, 280 460, 400 430 S 520 400, 640 440 S 720 410, 770 430",
"M 50 560 C 200 540, 340 580, 480 550 S 600 520, 680 560 S 750 530, 770 550",
"M 40 680 C 180 660, 320 700, 460 670 S 580 640, 700 680 S 760 650, 770 670",
"M 55 800 C 220 780, 360 820, 500 790 S 620 760, 720 800 S 760 770, 770 790",
"M 35 920 C 200 900, 350 940, 500 910 S 640 880, 720 920 S 760 890, 770 910",
"M 45 1040 C 190 1020, 340 1060, 490 1030 S 630 1000, 710 1040 S 760 1010, 770 1030",
] as const;
/** Terrace / tight contours — one flank (right-biased in default coords) */
export const CONTOUR_MINOR = [
"M 480 180 L 495 200 L 490 220 L 505 240 L 498 260 L 512 280 L 505 300",
"M 500 340 L 515 355 L 508 375 L 522 395 L 515 415 L 528 435 L 520 455",
"M 510 500 L 525 518 L 518 538 L 532 558 L 525 578 L 538 598 L 530 618",
"M 520 660 L 535 678 L 528 698 L 542 718 L 535 738 L 548 758 L 540 778",
"M 515 820 L 530 838 L 523 858 L 537 878 L 530 898 L 543 918 L 535 938",
"M 120 240 L 105 258 L 112 276 L 98 294 L 105 312 L 92 330",
"M 110 380 L 95 398 L 102 416 L 88 434 L 95 452 L 82 470",
"M 100 520 L 85 538 L 92 556 L 78 574 L 85 592 L 72 610",
] as const;
/** Stratum lines for pitch / program layers */
export const STRATUM_LINES = [
"M 80 400 L 720 400",
"M 100 480 L 700 480",
"M 120 560 L 680 560",
"M 140 640 L 660 640",
"M 160 720 L 640 720",
] as const;
export function getChannelTransform(bias: ChannelBias): string {
switch (bias) {
case "left":
return "translate(-8%, 0)";
case "right":
return "translate(8%, 0)";
default:
return "translate(0, 0)";
}
}
export function contourOpacity(
density: "low" | "medium" | "high",
kind: "major" | "minor"
): number {
const base = kind === "major" ? 0.14 : 0.22;
const mult = density === "low" ? 0.7 : density === "high" ? 1.25 : 1;
return Math.min(0.45, base * mult);
}

View File

@ -98,4 +98,9 @@ export const rootMetadata: Metadata = {
default: site.name, default: site.name,
template: `%s | ${site.shortName}`, template: `%s | ${site.shortName}`,
}, },
/** Static files in /public — avoids ImageResponse on Cloudflare Workers (500 on /favicon.ico). */
icons: {
icon: [{ url: "/favicon.ico", sizes: "any" }],
apple: "/apple-touch-icon.png",
},
}; };

View File

@ -1,5 +1,16 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
/**
* OpenNext miniflare/wrangler integration only when you need Cloudflare bindings in `next dev`
* (e.g. `getCloudflareContext`). Leave unset for normal local dev avoids extra patching and
* races with the dev server.
*
* OPENNEXT_CLOUDFLARE_DEV=1 npm run dev
*/
if (process.env.OPENNEXT_CLOUDFLARE_DEV === "1") {
void import("@opennextjs/cloudflare").then((m) => m.initOpenNextCloudflareForDev());
}
const nextConfig: NextConfig = {}; const nextConfig: NextConfig = {};
export default nextConfig; export default nextConfig;

3
open-next.config.ts Normal file
View File

@ -0,0 +1,3 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({});

5263
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,19 +2,26 @@
"name": "grv-summit", "name": "grv-summit",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"engines": {
"node": ">=20"
},
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev",
"dev:turbo": "next dev --turbopack",
"dev:clean": "rm -rf .next && next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"download-assets": "node scripts/download-assets.mjs", "download-assets": "node scripts/download-assets.mjs",
"deploy:cf": "npm run deploy", "deploy:cf": "npm run deploy",
"preview": "npx --yes @opennextjs/cloudflare@1.19.11 build && npx --yes @opennextjs/cloudflare@1.19.11 preview", "predeploy": "rm -rf .next .open-next",
"deploy": "npx --yes @opennextjs/cloudflare@1.19.11 build && npx --yes @opennextjs/cloudflare@1.19.11 deploy", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"upload": "npx --yes @opennextjs/cloudflare@1.19.11 build && npx --yes @opennextjs/cloudflare@1.19.11 upload", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"cf-typegen": "npx --yes wrangler@4 types --env-interface CloudflareEnv cloudflare-env.d.ts" "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/google-sans-flex": "^5.2.3",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
@ -35,6 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@opennextjs/cloudflare": "1.19.11",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
@ -42,6 +50,7 @@
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-next": "^15.3.3", "eslint-config-next": "^15.3.3",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"wrangler": "^4.0.0"
} }
} }

2
public/_headers Normal file
View File

@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

736
public/patterns/main.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 486 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 432 KiB