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) {