diff --git a/.git-push-log.sh b/.git-push-log.sh new file mode 100644 index 0000000..7bc1501 --- /dev/null +++ b/.git-push-log.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e +cd /root/Yaltopia-Project/GRV-Summit-Site +LOG=/root/Yaltopia-Project/GRV-Summit-Site/.deploy-log.txt +{ + echo "=== STATUS ===" + git status -sb + echo "=== ADD ===" + git add -A + git reset HEAD .env 2>/dev/null || true + echo "=== COMMIT ===" + git commit -m "$(cat <<'EOF' +Enhance footer and hero with brand geometric backgrounds. + +Add low-poly footer facets with curved midline, hero last-year winners +scroll, footer surfaces and social links, and supporting brand components. +EOF +)" || echo "COMMIT_SKIPPED: nothing to commit or failed" + echo "=== PUSH ===" + git push origin main + echo "=== LOG ===" + git log -1 --oneline + echo "=== GH RUNS ===" + gh run list --limit 3 2>&1 || echo "gh not available" +} > "$LOG" 2>&1 +echo DONE >> "$LOG" diff --git a/app/globals.css b/app/globals.css index 7120d15..98c0b37 100644 --- a/app/globals.css +++ b/app/globals.css @@ -103,10 +103,56 @@ background-color: #1a5c38; color: #fafafa; } + + @keyframes geometric-mess-drift { + 0% { + transform: translate(0, 0) scale(1); + } + 50% { + transform: translate(0.4%, -0.3%) scale(1.008); + } + 100% { + transform: translate(-0.3%, 0.2%) scale(1.004); + } + } + .geometric-mess-layer { + animation: geometric-mess-drift 56s ease-in-out infinite alternate; + } + .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"); } + /* White-section contour lines — slow parallax drift */ + @keyframes wavy-contour-drift-a { + 0% { + transform: translate(0, 0) scale(1); + } + 50% { + transform: translate(2.5%, -1.2%) scale(1.03); + } + 100% { + transform: translate(-1.5%, 0.8%) scale(1.01); + } + } + @keyframes wavy-contour-drift-b { + 0% { + transform: translate(0, 0) scale(1.02); + } + 50% { + transform: translate(-2%, 1%) scale(1.04); + } + 100% { + transform: translate(1.8%, -0.6%) scale(1.01); + } + } + .wavy-contour-layer--primary { + animation: wavy-contour-drift-a 48s ease-in-out infinite alternate; + } + .wavy-contour-layer--secondary { + animation: wavy-contour-drift-b 62s ease-in-out infinite alternate-reverse; + } + /* Full-page atmosphere — brand wash drift (behind grain + lines) */ @keyframes backdrop-bloom-a { 0%, @@ -157,6 +203,9 @@ .marquee { animation: marquee 40s linear infinite; } + .marquee-winners { + animation: marquee 55s linear infinite; + } @keyframes marquee { from { transform: translateX(0); @@ -166,7 +215,8 @@ } } @media (prefers-reduced-motion: reduce) { - .marquee { + .marquee, + .marquee-winners { animation: none; } .backdrop-bloom-a, @@ -523,11 +573,22 @@ z-index: 10; } + /* Landing hero — topography only, no contour-line overlay */ + [data-section-hero] .wavy-contour-lines { + display: none; + } + /* Readable brand-green copy on all white sections */ .section-white { color: #0d3d26; } + .section-white .topo-content-layer, + .section-white .topo-card-surface, + .section-white [data-slot="card"] { + isolation: isolate; + } + .section-white .topo-content-layer, .section-white .topo-content-readable, .section-white .topo-content-layer :is(h1, h2, h3, h4, h5, h6, p, li, label, summary), @@ -1042,6 +1103,13 @@ } @media (prefers-reduced-motion: reduce) { + .wavy-contour-layer--primary, + .wavy-contour-layer--secondary, + .wavy-contour-layer, + .geometric-mess-layer { + animation: none !important; + } + .rift-hero-intro .rift-floor-glow, .rift-hero-intro .rift-channel-open, .rift-hero-intro .rift-contour-path, diff --git a/components/brand/FooterTopoPattern.tsx b/components/brand/FooterTopoPattern.tsx index 955e02c..57a52fa 100644 --- a/components/brand/FooterTopoPattern.tsx +++ b/components/brand/FooterTopoPattern.tsx @@ -1,38 +1,45 @@ -import { MAIN_WHITE_PATTERN_SRC } from "@/content/topo-patterns"; +import { buildWavyTessellationMesh } from "@/components/brand/WavyTessellationMesh"; import { cn } from "@/lib/utils"; type Props = { className?: string; }; -/** White topography pattern anchored to the bottom of the green footer only. */ +/** Wavy interlocking tessellation across the green footer (GRV palette). */ +const FOOTER_MESH = buildWavyTessellationMesh(20, 38, 100, 100, { + diagonalSkew: 0.85, + amplitude: 0.68, +}); + export function FooterTopoPattern({ className }: Props) { return (
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - -
-
+ {FOOTER_MESH.map((cell, i) => ( + + ))} + +
); } diff --git a/components/brand/GeometricMessBackground.tsx b/components/brand/GeometricMessBackground.tsx new file mode 100644 index 0000000..514c29b --- /dev/null +++ b/components/brand/GeometricMessBackground.tsx @@ -0,0 +1,108 @@ +import { buildVoronoiMesh, type VoronoiCell } from "@/lib/voronoi-mesh"; +import { + buildFooterMidlineCurve, + midlineCurveToClipPath, + midlineCurveToClipPathPercent, +} from "@/lib/footer-curve-edge"; +import { cn } from "@/lib/utils"; + +const VIEW_W = 160; +const VIEW_H = 100; +const MIDLINE_Y = VIEW_H * 0.5; +const CURVE_SEED = 0xa3f21c; +const MIDLINE_CURVE = buildFooterMidlineCurve(VIEW_W, 20, CURVE_SEED, MIDLINE_Y, 11); +const FACET_CLIP_PATH = midlineCurveToClipPath(MIDLINE_CURVE, VIEW_W, VIEW_H); +const FACET_CLIP_CSS = midlineCurveToClipPathPercent(MIDLINE_CURVE, VIEW_W, VIEW_H); + +const FACET_TONES = [ + "#f5fbf7", + "#ecf8f0", + "#dff3e6", + "#ccebd8", + "#b5e4c8", + "#9ed9b4", + "#84d09f", + "#6bc98a", + "#52c878", + "#4aad6e", +] as const; + +const LOW_POLY_MESH = buildVoronoiMesh(64, VIEW_W, VIEW_H, CURVE_SEED, { + siteGenerator: "poisson", + shape: "sharp", + minDist: 6.5, +}); + +function hexToRgb(hex: string) { + const n = parseInt(hex.slice(1), 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function mixHex(a: string, b: string, t: number) { + const c1 = hexToRgb(a); + const c2 = hexToRgb(b); + const u = (v: number) => Math.round(v).toString(16).padStart(2, "0"); + return `#${u(c1.r + (c2.r - c1.r) * t)}${u(c1.g + (c2.g - c1.g) * t)}${u(c1.b + (c2.b - c1.b) * t)}`; +} + +function facetFill(cell: VoronoiCell, index: number) { + const cx = cell.points.reduce((s, p) => s + p[0], 0) / cell.points.length; + const cy = cell.points.reduce((s, p) => s + p[1], 0) / cell.points.length; + const xNorm = cx / VIEW_W; + const yNorm = Math.max(0, (cy - MIDLINE_Y) / (VIEW_H - MIDLINE_Y)); + const tone = FACET_TONES[(index * 9 + Math.floor(cx * 0.55)) % FACET_TONES.length]; + const wash = mixHex("#ffffff", "#e8f5ec", 0.35); + const mix = 0.42 + (1 - xNorm) * 0.2 + yNorm * 0.12; + return mixHex(wash, tone, mix); +} + +const FACET_CELLS = LOW_POLY_MESH.map((cell, i) => ({ + d: cell.d, + fill: facetFill(cell, i), +})); + +type Props = { + className?: string; +}; + +export function GeometricMessBackground({ className }: Props) { + return ( +
+
+ + + + + + + + + {FACET_CELLS.map((cell, i) => ( + + ))} + + +
+ + {/* White wash above the curve only — keeps facet tops along the midline visible */} +
+
+ ); +} diff --git a/components/brand/LowPolyGeometricBackground.tsx b/components/brand/LowPolyGeometricBackground.tsx new file mode 100644 index 0000000..f7bd276 --- /dev/null +++ b/components/brand/LowPolyGeometricBackground.tsx @@ -0,0 +1 @@ +export { RoundedRockVoronoiBackground as LowPolyGeometricBackground } from "@/components/brand/RoundedRockVoronoiBackground"; diff --git a/components/brand/RoundedRockVoronoiBackground.tsx b/components/brand/RoundedRockVoronoiBackground.tsx new file mode 100644 index 0000000..02832ff --- /dev/null +++ b/components/brand/RoundedRockVoronoiBackground.tsx @@ -0,0 +1,95 @@ +import type { CSSProperties } from "react"; +import { buildVoronoiMesh, type VoronoiCell } from "@/lib/voronoi-mesh"; +import { cn } from "@/lib/utils"; + +/** Wider mesh plane — pairs with slice scaling so pebbles are not horizontally stretched. */ +const MESH_W = 140; +const MESH_H = 100; + +const ROCK_TONES = ["#0d3d26", "#1a5c38", "#246b45", "#2f7a52", "#3d9a66", "#52b87a"] as const; +const GROUT = "rgba(8, 38, 22, 0.42)"; + +const ROCK_MESH = buildVoronoiMesh(42, MESH_W, MESH_H, 0x475256, { + siteGenerator: "poisson", +}); + +function hexToRgb(hex: string) { + const n = parseInt(hex.slice(1), 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function mixHex(a: string, b: string, t: number) { + const c1 = hexToRgb(a); + const c2 = hexToRgb(b); + const u = (v: number) => Math.round(v).toString(16).padStart(2, "0"); + return `#${u(c1.r + (c2.r - c1.r) * t)}${u(c1.g + (c2.g - c1.g) * t)}${u(c1.b + (c2.b - c1.b) * t)}`; +} + +function rockFill(cell: VoronoiCell, index: number) { + const cx = cell.points.reduce((s, p) => s + p[0], 0) / cell.points.length; + const cy = cell.points.reduce((s, p) => s + p[1], 0) / cell.points.length; + const yNorm = cy / MESH_H; + const gradient = mixHex("#4aad6e", "#0d3d26", Math.min(1, yNorm * 0.85 + 0.08)); + const tone = ROCK_TONES[(index * 11 + Math.floor(cx * 0.55)) % ROCK_TONES.length]; + return mixHex(gradient, tone, 0.54); +} + +type Props = { + className?: string; + style?: CSSProperties; + /** Slight vertical wash (two-days section). */ + gradient?: boolean; +}; + +export function RoundedRockVoronoiBackground({ + className, + style, + gradient = false, +}: Props) { + const cells = ROCK_MESH.map((cell, i) => ({ + d: cell.d, + fill: rockFill(cell, i), + })); + + return ( +
+ + {cells.map((cell, i) => ( + + ))} + +
+ {gradient && ( +
+ )} +
+
+ ); +} diff --git a/components/brand/StainedGlassGradientBackground.tsx b/components/brand/StainedGlassGradientBackground.tsx new file mode 100644 index 0000000..cd22a8f --- /dev/null +++ b/components/brand/StainedGlassGradientBackground.tsx @@ -0,0 +1 @@ +export { RoundedRockVoronoiBackground as StainedGlassGradientBackground } from "@/components/brand/RoundedRockVoronoiBackground"; diff --git a/components/brand/WavyContourLinesBackground.tsx b/components/brand/WavyContourLinesBackground.tsx new file mode 100644 index 0000000..769b94b --- /dev/null +++ b/components/brand/WavyContourLinesBackground.tsx @@ -0,0 +1,78 @@ +import { buildWavyContourPaths } from "@/lib/wavy-contour-lines"; +import { cn } from "@/lib/utils"; + +const VIEW_W = 160; +const VIEW_H = 100; + +const CONTOUR_PRIMARY = buildWavyContourPaths(28, VIEW_W, VIEW_H, 0x8a3c21); +const CONTOUR_SECONDARY = buildWavyContourPaths(22, VIEW_W, VIEW_H, 0x5c2e18); + +type Props = { + className?: string; +}; + +const frameClass = + "absolute left-1/2 top-1/2 h-[118%] w-[118%] min-h-full min-w-full -translate-x-1/2 -translate-y-1/2"; + +/** + * Topographic waves on white sections — visible hairlines with slow drift. + * Not used on the home hero (keeps existing topography art). + */ +export function WavyContourLinesBackground({ className }: Props) { + return ( +
+
+
+ + {CONTOUR_PRIMARY.map((d, i) => ( + + ))} + +
+
+
+
+ + {CONTOUR_SECONDARY.map((d, i) => ( + + ))} + +
+
+
+ ); +} diff --git a/components/brand/WavyTessellationMesh.tsx b/components/brand/WavyTessellationMesh.tsx new file mode 100644 index 0000000..f9dd3dc --- /dev/null +++ b/components/brand/WavyTessellationMesh.tsx @@ -0,0 +1,94 @@ +/** GRV greens for wavy interlocking footer tessellation. */ +export const WAVY_TESS_PALETTE = [ + "#0d3d26", + "#1a5c38", + "#256b45", + "#2d7a52", + "#3d9a66", + "#5cb87a", +] as const; + +export type WavyTessCell = { d: string; fill: string }; + +function hashCell(col: number, row: number, cols: number) { + return (col * 17 + row * 31 + ((col + row) % cols) * 11) % WAVY_TESS_PALETTE.length; +} + +type MeshOptions = { + /** Tilts wave flow diagonally (higher = more angled, less vertical). */ + diagonalSkew?: number; + amplitude?: number; +}; + +/** + * Diagonal wavy columns + horizontal ripples — interlocking mosaic (not flat vertical stripes). + */ +export function buildWavyTessellationMesh( + cols: number, + rows: number, + width = 100, + height = 100, + options: MeshOptions = {} +): WavyTessCell[] { + const skew = options.diagonalSkew ?? 0.72; + const amp = (width / cols) * (options.amplitude ?? 0.62); + const ampRow = amp * 0.48; + const loops = 3.6; + + const boundaryX = (col: number, yNorm: number) => { + const base = (col / cols) * width; + const phase = col * 1.13 + 0.42; + const u = yNorm + (col / cols) * skew; + const v = yNorm - (col / cols) * skew * 0.45; + const wave = + Math.sin(u * Math.PI * 2 * loops + phase) + + 0.52 * Math.sin(v * Math.PI * 2 * (loops + 0.85) - col * 0.4) + + 0.28 * Math.cos((u + v) * Math.PI * loops * 0.55 + phase * 0.6); + return base + amp * wave * 0.42; + }; + + const boundaryY = (row: number, xNorm: number) => { + const base = (row / rows) * height; + const phase = row * 0.91 + 0.18; + const u = xNorm + (row / rows) * skew; + const wave = + Math.sin(u * Math.PI * 2 * (loops * 0.9) + phase) + + 0.4 * Math.cos(u * Math.PI * 2 * (loops * 1.15) - row * 0.55); + return base + ampRow * wave * 0.38; + }; + + const cells: WavyTessCell[] = []; + + const corner = (col: number, row: number) => { + const yn = row / rows; + const x = boundaryX(col, yn); + const y = boundaryY(row, x / width); + return { x, y }; + }; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const bl = corner(c, r); + const br = corner(c + 1, r); + const tl = corner(c, r + 1); + const tr = corner(c + 1, r + 1); + const ymL = (bl.y + tl.y) / 2; + const ymR = (br.y + tr.y) / 2; + + const d = [ + `M ${bl.x.toFixed(2)} ${bl.y.toFixed(2)}`, + `Q ${bl.x.toFixed(2)} ${ymL.toFixed(2)} ${tl.x.toFixed(2)} ${tl.y.toFixed(2)}`, + `L ${tr.x.toFixed(2)} ${tr.y.toFixed(2)}`, + `Q ${br.x.toFixed(2)} ${ymR.toFixed(2)} ${br.x.toFixed(2)} ${br.y.toFixed(2)}`, + "Z", + ].join(" "); + + const layer = (r + Math.floor(c / 2)) % 3; + const fill = WAVY_TESS_PALETTE[hashCell(c + layer, r, cols)]; + + cells.push({ d, fill }); + } + } + + return cells; +} diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index eafb3e8..e9227cf 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { ArrowRight } from "lucide-react"; import { site } from "@/content/site"; import { HeroGrantLine } from "@/components/home/HeroGrantLine"; +import { LastYearWinnersScroll } from "@/components/home/LastYearWinnersScroll"; import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend"; import { HeroTopographyBackground } from "@/components/home/HeroTopographyBackground"; import { HeroRiftParticles } from "@/components/home/HeroRiftParticles"; @@ -60,6 +61,8 @@ export function Hero() { return (
-
- +
+

{site.dates.label} · {site.venue.address} @@ -128,6 +131,8 @@ export function Hero() {

+ +
diff --git a/components/home/LastYearWinnerMark.tsx b/components/home/LastYearWinnerMark.tsx new file mode 100644 index 0000000..c034f08 --- /dev/null +++ b/components/home/LastYearWinnerMark.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import type { LastYearWinner } from "@/content/last-year-winners"; +import { cn } from "@/lib/utils"; + +const GRV_LOGO = "/branding/logo-icon.png"; + +type Props = { + company: LastYearWinner; + className?: string; +}; + +export function LastYearWinnerMark({ company, className }: Props) { + const [failed, setFailed] = useState(false); + const src = company.logoSrc ?? GRV_LOGO; + const showImage = !failed && src; + const logoOnly = !company.name; + + return ( +
+
+ {showImage ? ( + setFailed(true)} + /> + ) : company.initials ? ( + + {company.initials} + + ) : ( + + )} +
+ {company.name ? ( + + {company.name} + + ) : null} +
+ ); +} diff --git a/components/home/LastYearWinnersScroll.tsx b/components/home/LastYearWinnersScroll.tsx new file mode 100644 index 0000000..0e96743 --- /dev/null +++ b/components/home/LastYearWinnersScroll.tsx @@ -0,0 +1,62 @@ +import { lastYearWinners, lastYearWinnersCopy } from "@/content/last-year-winners"; +import { LastYearWinnerMark } from "@/components/home/LastYearWinnerMark"; +import { cn } from "@/lib/utils"; + +type Props = { + /** Green stats section vs white hero */ + variant?: "on-green" | "on-light"; + className?: string; +}; + +export function LastYearWinnersScroll({ variant = "on-green", className }: Props) { + const items = [...lastYearWinners, ...lastYearWinners]; + const onLight = variant === "on-light"; + + return ( +
+

+ {lastYearWinnersCopy.eyebrow} +

+

+ {lastYearWinnersCopy.headline} +

+
+
+
+
+ {items.map((company, i) => ( + + ))} +
+
+
+ ); +} diff --git a/components/home/PartnerMarquee.tsx b/components/home/PartnerMarquee.tsx index d1ceb60..0fb668c 100644 --- a/components/home/PartnerMarquee.tsx +++ b/components/home/PartnerMarquee.tsx @@ -1,14 +1,16 @@ import { PartnerLogoPlaceholder } from "@/components/brand/PartnerLogoPlaceholder"; +import { WavyContourLinesBackground } from "@/components/brand/WavyContourLinesBackground"; export function PartnerMarquee() { const slots = Array.from({ length: 8 }, (_, i) => i); return (
-

+ +

With the support of

-
+
{[...slots, ...slots].map((i) => ( diff --git a/components/home/StatsGrid.tsx b/components/home/StatsGrid.tsx index 5b77319..ff439d3 100644 --- a/components/home/StatsGrid.tsx +++ b/components/home/StatsGrid.tsx @@ -2,7 +2,6 @@ import { site } from "@/content/site"; import { Section } from "@/components/layout/Section"; import { TopoProseSurface } from "@/components/layout/TopoProseSurface"; import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount"; - export function StatsGrid() { return (
diff --git a/components/layout/FooterNewsletter.tsx b/components/layout/FooterNewsletter.tsx index cba8ebf..7fac037 100644 --- a/components/layout/FooterNewsletter.tsx +++ b/components/layout/FooterNewsletter.tsx @@ -52,11 +52,11 @@ export function FooterNewsletter() { } return ( -
+
-

Stay up to date!

+

Stay up to date!

Get announcements about tickets, lineup, and the next Great Rift Valley Innovation Summit edition before anyone else. diff --git a/components/layout/FooterSocialLinks.tsx b/components/layout/FooterSocialLinks.tsx new file mode 100644 index 0000000..ee7bb89 --- /dev/null +++ b/components/layout/FooterSocialLinks.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; +import { site } from "@/content/site"; +import { cn } from "@/lib/utils"; + +type IconProps = { className?: string }; + +function TikTokIcon({ className }: IconProps) { + return ( + + + + ); +} + +function LinkedInIcon({ className }: IconProps) { + return ( + + + + ); +} + +function FacebookIcon({ className }: IconProps) { + return ( + + + + ); +} + +function InstagramIcon({ className }: IconProps) { + return ( + + + + ); +} + +const SOCIAL = [ + { id: "tiktok", label: "TikTok", href: site.social.tiktok, Icon: TikTokIcon }, + { id: "linkedin", label: "LinkedIn", href: site.social.linkedin, Icon: LinkedInIcon }, + { id: "facebook", label: "Facebook", href: site.social.facebook, Icon: FacebookIcon }, + { id: "instagram", label: "Instagram", href: site.social.instagram, Icon: InstagramIcon }, +] as const; + +type Props = { + className?: string; +}; + +export function FooterSocialLinks({ className }: Props) { + return ( +

+ Follow GRV Summit + {SOCIAL.map(({ id, label, href, Icon }) => ( + + + + ))} +
+ ); +} diff --git a/components/layout/FooterSurface.tsx b/components/layout/FooterSurface.tsx new file mode 100644 index 0000000..3d70e16 --- /dev/null +++ b/components/layout/FooterSurface.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +type Props = { + children: ReactNode; + className?: string; +}; + +/** White panel so footer copy and icons stay readable over the geometric pattern. */ +export function FooterSurface({ children, className }: Props) { + return ( +
+ {children} +
+ ); +} diff --git a/components/layout/PageRiftHeader.tsx b/components/layout/PageRiftHeader.tsx index 2b86698..8bc612b 100644 --- a/components/layout/PageRiftHeader.tsx +++ b/components/layout/PageRiftHeader.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend"; +import { WavyContourLinesBackground } from "@/components/brand/WavyContourLinesBackground"; import { TopoProseSurface } from "@/components/layout/TopoProseSurface"; import { TopoSectionProvider } from "@/components/layout/TopoSectionContext"; import { cn } from "@/lib/utils"; @@ -43,6 +44,7 @@ export function PageRiftHeader({ className )} > +
diff --git a/components/layout/Section.tsx b/components/layout/Section.tsx index ca6baf8..aca6514 100644 --- a/components/layout/Section.tsx +++ b/components/layout/Section.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +import { RoundedRockVoronoiBackground } from "@/components/brand/RoundedRockVoronoiBackground"; import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend"; +import { WavyContourLinesBackground } from "@/components/brand/WavyContourLinesBackground"; import { ScrollReveal } from "@/components/motion/ScrollReveal"; import { TopoSectionProvider } from "@/components/layout/TopoSectionContext"; import { toneFromSectionVariant, type TopoPatternId } from "@/content/topo-patterns"; @@ -9,6 +11,8 @@ type Props = { id?: string; className?: string; children: ReactNode; + /** Override section backdrop (rock on green, contours on white); pass `null` to hide. */ + background?: ReactNode | null; variant?: "default" | "muted" | "inverse"; /** @deprecated Patterns are hero + footer only; kept for API compatibility */ riftPattern?: TopoPatternId; @@ -20,6 +24,7 @@ export function Section({ id, className, children, + background, variant = "default", riftPattern, riftFlow, @@ -42,6 +47,21 @@ export function Section({ )} data-section-tone={tone} > + {isGreen && + (background !== undefined ? ( + background + ) : ( + + ))} + {!isGreen && + (background !== undefined ? ( + background + ) : ( + + ))} {!isGreen && } - +
+ -
+
-
-
-
- {footerColumns.map((col) => ( -
-

{col.title}

-
    - {col.links.map((link) => ( -
  • - - {link.label} - -
  • - ))} -
+
+ +
+ {footerColumns.map((col) => ( +
+

{col.title}

+
    + {col.links.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ ))}
- ))} -
-
-
-

- {site.shortName} · {site.dates.label} · Presented by {site.presentedBy} -

-

- © {new Date().getFullYear()} Ethiopian Diaspora Trust Fund. All rights reserved. -

-
+
+
+

+ Follow us +

+ +
+
+ + + +
+

+ {site.shortName} · {site.dates.label} · Presented by {site.presentedBy} +

+

+ © {new Date().getFullYear()} Ethiopian Diaspora Trust Fund. All rights reserved. +

+
+
diff --git a/components/partners/PartnershipCtaBand.tsx b/components/partners/PartnershipCtaBand.tsx index 274dd13..ac8c7cd 100644 --- a/components/partners/PartnershipCtaBand.tsx +++ b/components/partners/PartnershipCtaBand.tsx @@ -1,3 +1,4 @@ +import { RoundedRockVoronoiBackground } from "@/components/brand/RoundedRockVoronoiBackground"; import { partnershipCta } from "@/content/partners"; import { TopoProseSurface } from "@/components/layout/TopoProseSurface"; import { TopoSectionProvider } from "@/components/layout/TopoSectionContext"; @@ -10,6 +11,7 @@ export function PartnershipCtaBand() { id="partnership-form" className="group/topo-section section-green relative isolate overflow-hidden bg-[#1a5c38] py-16 md:py-24" > +
diff --git a/content/last-year-winners.ts b/content/last-year-winners.ts new file mode 100644 index 0000000..9ac3280 --- /dev/null +++ b/content/last-year-winners.ts @@ -0,0 +1,47 @@ +export type LastYearWinner = { + id: string; + /** Display name; omit for logo-only GRV placeholders */ + name?: string; + /** Local path under /public; drop files in public/branding/winners/ */ + logoSrc?: string; + /** Shown when logo image is missing */ + initials?: string; +}; + +export const lastYearWinnersCopy = { + eyebrow: "Last year's summit", + headline: "18+ companies supported", +} as const; + +const GRV_LOGO = "/branding/logo-icon.png"; + +/** Featured alumni — replace logo files in public/branding/winners/ when ready */ +const featured: LastYearWinner[] = [ + { + id: "lifeline-addis", + name: "Lifeline Addis", + logoSrc: "/branding/winners/lifeline-addis.png", + initials: "LA", + }, + { + id: "globedock-academy", + name: "Globedock Academy", + logoSrc: "/branding/winners/globedock-academy.png", + initials: "GD", + }, + { + id: "muyalogy", + name: "Muyalogy", + logoSrc: "/branding/winners/muyalogy.png", + initials: "MY", + }, +]; + +/** Placeholders — GRV mark until logos are added */ +const placeholderCount = 15; +const placeholders: LastYearWinner[] = Array.from({ length: placeholderCount }, (_, i) => ({ + id: `alumni-${i + 1}`, + logoSrc: GRV_LOGO, +})); + +export const lastYearWinners: LastYearWinner[] = [...featured, ...placeholders]; diff --git a/content/site.ts b/content/site.ts index 8eb9f99..6ac1a86 100644 --- a/content/site.ts +++ b/content/site.ts @@ -22,6 +22,12 @@ export const site = { legacySite: "https://grvsummit.com/", calendarIcs: "/calendar", }, + social: { + tiktok: "https://www.tiktok.com/@grvsummit", + linkedin: "https://www.linkedin.com/company/grv-summit", + facebook: "https://www.facebook.com/grvsummit", + instagram: "https://www.instagram.com/grvsummit", + }, stats: [ { type: "static", value: "500+", label: "Attendees" }, { type: "cycling", label: "Grant funding" }, diff --git a/lib/footer-curve-edge.ts b/lib/footer-curve-edge.ts new file mode 100644 index 0000000..e6edf69 --- /dev/null +++ b/lib/footer-curve-edge.ts @@ -0,0 +1,61 @@ +import type { Vec2 } from "@/lib/voronoi-mesh"; + +function mulberry32(seed: number) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Gentle wave along the footer midline — peaks cross 50% so facet tops stay visible. */ +export function buildFooterMidlineCurve( + width: number, + segments: number, + seed: number, + midY: number, + amplitude: number +): Vec2[] { + const rand = mulberry32(seed); + const pts: Vec2[] = [[0, midY + (rand() - 0.5) * amplitude * 0.35]]; + for (let i = 1; i <= segments; i++) { + const x = (width * i) / segments; + const t = i / segments; + const wave = + Math.sin(t * Math.PI * 3.8 + seed * 0.002) * amplitude * 0.5 + + Math.sin(t * Math.PI * 7.4 + 0.8) * amplitude * 0.22; + const jitter = (rand() - 0.5) * amplitude * 0.4; + const y = midY + wave + jitter; + pts.push([x, Math.max(midY - amplitude * 0.85, Math.min(midY + amplitude * 0.9, y))]); + } + return pts; +} + +/** Closed SVG path: curved top, square bottom (clip region for facets). */ +export function midlineCurveToClipPath(edge: Vec2[], width: number, height: number): string { + if (edge.length < 2) return ""; + const [x0, y0] = edge[0]; + let d = `M ${x0},${y0}`; + for (let i = 1; i < edge.length; i++) { + const [x, y] = edge[i]; + const [px, py] = edge[i - 1]; + const cx = (px + x) / 2; + d += ` Q ${cx},${py} ${x},${y}`; + } + d += ` L ${width},${height} L 0,${height} Z`; + return d; +} + +/** CSS clip-path (percent) — matches SVG curve for the HTML layer. */ +export function midlineCurveToClipPathPercent( + edge: Vec2[], + width: number, + height: number +): string { + const pct = (x: number, y: number) => + `${((x / width) * 100).toFixed(2)}% ${((y / height) * 100).toFixed(2)}%`; + const top = edge.map(([x, y]) => pct(x, y)).join(", "); + return `polygon(${top}, 100% 100%, 0% 100%)`; +} diff --git a/lib/voronoi-mesh.ts b/lib/voronoi-mesh.ts new file mode 100644 index 0000000..ba44289 --- /dev/null +++ b/lib/voronoi-mesh.ts @@ -0,0 +1,213 @@ +export type Vec2 = [number, number]; + +export type VoronoiCell = { + d: string; + points: Vec2[]; +}; + +function distSq(a: Vec2, b: Vec2) { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + return dx * dx + dy * dy; +} + +function intersectEdge(a: Vec2, b: Vec2, keep: Vec2, other: Vec2): Vec2 { + const da = distSq(a, keep) - distSq(a, other); + const db = distSq(b, keep) - distSq(b, other); + const denom = da - db; + const t = Math.abs(denom) < 1e-9 ? 0.5 : da / denom; + return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]; +} + +function clipPolygonByHalfPlane(poly: Vec2[], keep: Vec2, other: Vec2): Vec2[] { + if (poly.length === 0) return []; + const inside = (p: Vec2) => distSq(p, keep) <= distSq(p, other) + 1e-6; + const out: Vec2[] = []; + + for (let i = 0; i < poly.length; i++) { + const curr = poly[i]; + const prev = poly[(i - 1 + poly.length) % poly.length]; + const currIn = inside(curr); + const prevIn = inside(prev); + + if (prevIn && currIn) out.push(curr); + else if (prevIn && !currIn) out.push(intersectEdge(prev, curr, keep, other)); + else if (!prevIn && currIn) { + out.push(intersectEdge(prev, curr, keep, other)); + out.push(curr); + } + } + + return out; +} + +function voronoiPolygon(site: Vec2, sites: Vec2[], bounds: Vec2[]): Vec2[] { + let poly = bounds; + for (const other of sites) { + if (other === site) continue; + poly = clipPolygonByHalfPlane(poly, site, other); + if (poly.length < 3) return []; + } + return poly; +} + +export function polygonPath(points: Vec2[]): string { + if (points.length < 3) return ""; + return ( + points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p[0].toFixed(2)} ${p[1].toFixed(2)}`) + .join(" ") + " Z" + ); +} + +function len(a: Vec2, b: Vec2) { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} + +/** Soft pebble edges — rounded corners on each Voronoi cell. */ +export function roundedPolygonPath(points: Vec2[], radius = 1.35): string { + const n = points.length; + if (n < 3) return polygonPath(points); + + const parts: string[] = []; + + for (let i = 0; i < n; i++) { + const prev = points[(i - 1 + n) % n]; + const curr = points[i]; + const next = points[(i + 1) % n]; + + const e1: Vec2 = [prev[0] - curr[0], prev[1] - curr[1]]; + const e2: Vec2 = [next[0] - curr[0], next[1] - curr[1]]; + const l1 = Math.hypot(e1[0], e1[1]) || 1; + const l2 = Math.hypot(e2[0], e2[1]) || 1; + const cut = Math.min(radius, l1 * 0.38, l2 * 0.38); + + const p1: Vec2 = [curr[0] + (e1[0] / l1) * cut, curr[1] + (e1[1] / l1) * cut]; + const p2: Vec2 = [curr[0] + (e2[0] / l2) * cut, curr[1] + (e2[1] / l2) * cut]; + + if (i === 0) parts.push(`M ${p1[0].toFixed(2)} ${p1[1].toFixed(2)}`); + else parts.push(`L ${p1[0].toFixed(2)} ${p1[1].toFixed(2)}`); + parts.push(`Q ${curr[0].toFixed(2)} ${curr[1].toFixed(2)} ${p2[0].toFixed(2)} ${p2[1].toFixed(2)}`); + } + + return parts.join(" ") + " Z"; +} + +/** Radial pebble field — smaller cells near center, larger toward edges (rock mosaic). */ +export function generateRadialRockSites( + count: number, + width: number, + height: number, + seed: number +): Vec2[] { + const rng = createRng(seed); + const cx = width / 2; + const cy = height / 2; + const sites: Vec2[] = []; + + for (let i = 0; i < count; i++) { + const angle = rng() * Math.PI * 2; + const r = Math.pow(rng(), 0.55) * 0.46; + sites.push([cx + Math.cos(angle) * r * width, cy + Math.sin(angle) * r * height * 0.92]); + } + + return sites; +} + +export function createRng(seed: number) { + let s = seed % 2147483646 || 1; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +export function generatePoissonSites( + count: number, + width: number, + height: number, + seed: number, + minDist: number +): Vec2[] { + const rng = createRng(seed); + const sites: Vec2[] = []; + let attempts = 0; + + while (sites.length < count && attempts < count * 120) { + const p: Vec2 = [rng() * width, rng() * height]; + if (sites.every((s) => distSq(s, p) >= minDist * minDist)) sites.push(p); + attempts++; + } + + return sites; +} + +/** Ghost sites pull tessellation to the edges (stained-glass border). */ +export function borderGhostSites(width: number, height: number, pad = 18): Vec2[] { + const ghosts: Vec2[] = []; + const steps = 5; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + ghosts.push([-pad, height * t]); + ghosts.push([width + pad, height * t]); + ghosts.push([width * t, -pad]); + ghosts.push([width * t, height + pad]); + } + return ghosts; +} + +export type VoronoiMeshOptions = { + siteGenerator?: "poisson" | "radial"; + shape?: "rounded" | "sharp"; + /** Poisson minimum spacing — lower = finer cells */ + minDist?: number; + cornerRadiusMax?: number; + cornerRadiusFactor?: number; +}; + +export function buildVoronoiMesh( + siteCount: number, + width: number, + height: number, + seed: number, + options: VoronoiMeshOptions = {} +): VoronoiCell[] { + const { + siteGenerator = "poisson", + shape = "rounded", + minDist = 8, + cornerRadiusMax = 2.2, + cornerRadiusFactor = 0.22, + } = options; + const sites = + siteGenerator === "radial" + ? generateRadialRockSites(siteCount, width, height, seed) + : generatePoissonSites(siteCount, width, height, seed, minDist); + const allSites = [...sites, ...borderGhostSites(width, height)]; + const bounds: Vec2[] = [ + [0, 0], + [width, 0], + [width, height], + [0, height], + ]; + + const cells: VoronoiCell[] = []; + + for (const site of sites) { + const points = voronoiPolygon(site, allSites, bounds); + if (points.length < 3) continue; + const avgEdge = + points.reduce((sum, p, i) => sum + len(p, points[(i + 1) % points.length]), 0) / + points.length; + const d = + shape === "sharp" + ? polygonPath(points) + : roundedPolygonPath( + points, + Math.min(cornerRadiusMax, avgEdge * cornerRadiusFactor) + ); + if (d) cells.push({ d, points }); + } + + return cells; +} diff --git a/lib/wavy-contour-lines.ts b/lib/wavy-contour-lines.ts new file mode 100644 index 0000000..c7baf17 --- /dev/null +++ b/lib/wavy-contour-lines.ts @@ -0,0 +1,38 @@ +import { createRng } from "@/lib/voronoi-mesh"; + +/** Organic contour paths for light backgrounds (topographic wave lines). */ +export function buildWavyContourPaths( + lineCount: number, + width: number, + height: number, + seed: number +): string[] { + const rng = createRng(seed); + const paths: string[] = []; + + for (let i = 0; i < lineCount; i++) { + const yBase = ((i + 0.5) / lineCount) * height; + const amp = 2 + rng() * 4.5; + const freq1 = 0.035 + rng() * 0.038; + const freq2 = 0.008 + rng() * 0.014; + const phase1 = rng() * Math.PI * 2; + const phase2 = rng() * Math.PI * 2; + const drift = (rng() - 0.5) * 3; + const steps = 80; + let d = ""; + + for (let s = 0; s <= steps; s++) { + const x = (s / steps) * width; + const y = + yBase + + drift * (x / width - 0.5) + + amp * Math.sin(x * freq1 + phase1) + + amp * 0.42 * Math.sin(x * freq2 + phase2); + d += s === 0 ? `M ${x.toFixed(2)} ${y.toFixed(2)}` : ` L ${x.toFixed(2)} ${y.toFixed(2)}`; + } + + paths.push(d); + } + + return paths; +} diff --git a/public/branding/winners/.gitkeep b/public/branding/winners/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/branding/winners/globedock-academy.png b/public/branding/winners/globedock-academy.png new file mode 100644 index 0000000..e5c42e0 Binary files /dev/null and b/public/branding/winners/globedock-academy.png differ diff --git a/public/branding/winners/lifeline-addis.png b/public/branding/winners/lifeline-addis.png new file mode 100644 index 0000000..4564aa6 Binary files /dev/null and b/public/branding/winners/lifeline-addis.png differ diff --git a/public/branding/winners/muyalogy.png b/public/branding/winners/muyalogy.png new file mode 100644 index 0000000..c7d7020 Binary files /dev/null and b/public/branding/winners/muyalogy.png differ diff --git a/scripts/download-assets.mjs b/scripts/download-assets.mjs index c3d47b0..e46fe1d 100644 --- a/scripts/download-assets.mjs +++ b/scripts/download-assets.mjs @@ -28,6 +28,18 @@ const assets = [ { url: `${base}/2025/02/lulite_edited-removebg-preview.png`, dest: "public/branding/speakers/lulite.png" }, { url: `${base}/2025/02/dagmawit_edited-removebg-preview.png`, dest: "public/branding/speakers/dagmawit.png" }, { url: `${base}/2025/02/samiya_edited-removebg-preview.png`, dest: "public/branding/speakers/samiya.png" }, + { + url: "https://www.google.com/s2/favicons?domain=lifelineaddis.com&sz=128", + dest: "public/branding/winners/lifeline-addis.png", + }, + { + url: "https://www.google.com/s2/favicons?domain=globedock.et&sz=128", + dest: "public/branding/winners/globedock-academy.png", + }, + { + url: "https://www.google.com/s2/favicons?domain=muyalogy.com&sz=128", + dest: "public/branding/winners/muyalogy.png", + }, ]; async function download(url, dest) {