Enhance footer and hero with brand backgrounds
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run

This commit is contained in:
Kirubel-Kibru-Yaltopia 2026-05-21 20:35:59 +03:00
parent 2b419883eb
commit d261148b35
31 changed files with 1174 additions and 64 deletions

26
.git-push-log.sh Normal file
View File

@ -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"

View File

@ -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,

View File

@ -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 (
<div
className={cn(
"topo-footer-pattern pointer-events-none absolute inset-x-0 bottom-0 z-0 h-[min(42vh,320px)] overflow-hidden",
"pointer-events-none absolute inset-0 z-0 overflow-hidden bg-[#1a5c38]",
className
)}
aria-hidden
>
<div
className="absolute inset-x-0 bottom-0 h-full"
style={{
maskImage:
"linear-gradient(to top, black 0%, black 38%, rgba(0,0,0,0.55) 62%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to top, black 0%, black 38%, rgba(0,0,0,0.55) 62%, transparent 100%)",
}}
<svg
className="absolute left-1/2 top-1/2 h-[135%] w-[135%] -translate-x-1/2 -translate-y-1/2 -rotate-[14deg] scale-110"
viewBox="0 0 100 100"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={MAIN_WHITE_PATTERN_SRC}
alt=""
className="topo-footer-pattern-asset block h-full w-full object-cover object-bottom"
draggable={false}
/>
</div>
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-[#1a5c38] to-transparent" />
{FOOTER_MESH.map((cell, i) => (
<path
key={i}
d={cell.d}
fill={cell.fill}
stroke="rgba(255,255,255,0.09)"
strokeWidth={0.14}
/>
))}
</svg>
<div
className="absolute inset-0 bg-gradient-to-t from-[#0d3d26]/35 via-transparent to-transparent"
aria-hidden
/>
</div>
);
}

View File

@ -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 (
<div
className={cn(
"pointer-events-none absolute inset-0 z-0 overflow-hidden bg-white",
className
)}
aria-hidden
>
<div
className="geometric-mess-layer absolute inset-0"
style={{ clipPath: FACET_CLIP_CSS }}
>
<svg
className="h-full w-full"
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<clipPath id="footer-facet-clip" clipPathUnits="userSpaceOnUse">
<path d={FACET_CLIP_PATH} />
</clipPath>
</defs>
<rect width={VIEW_W} height={VIEW_H} fill="#ffffff" />
<g clipPath="url(#footer-facet-clip)">
{FACET_CELLS.map((cell, i) => (
<path key={i} d={cell.d} fill={cell.fill} stroke="none" />
))}
</g>
</svg>
</div>
{/* White wash above the curve only — keeps facet tops along the midline visible */}
<div
className="absolute inset-x-0 top-0 h-[44%] bg-gradient-to-b from-white via-white/75 to-transparent"
aria-hidden
/>
</div>
);
}

View File

@ -0,0 +1 @@
export { RoundedRockVoronoiBackground as LowPolyGeometricBackground } from "@/components/brand/RoundedRockVoronoiBackground";

View File

@ -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 (
<div
className={cn(
"pointer-events-none absolute overflow-hidden bg-[#1a5c38]",
style?.height != null ? "left-0 right-0" : "inset-0",
className
)}
style={style}
aria-hidden
>
<svg
className="absolute left-1/2 top-1/2 h-full w-full min-h-full min-w-full -translate-x-1/2 -translate-y-1/2"
viewBox={`0 0 ${MESH_W} ${MESH_H}`}
preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg"
>
{cells.map((cell, i) => (
<path
key={i}
d={cell.d}
fill={cell.fill}
stroke={GROUT}
strokeWidth={0.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
))}
</svg>
<div className="absolute inset-0 opacity-[0.18] grain" aria-hidden />
{gradient && (
<div
className="absolute inset-0 bg-gradient-to-b from-[#4aad6e]/14 via-transparent to-[#0d3d26]/18"
aria-hidden
/>
)}
<div
className="absolute inset-0 bg-[radial-gradient(ellipse_85%_60%_at_20%_15%,rgba(13,61,38,0.2),transparent_65%)]"
aria-hidden
/>
</div>
);
}

View File

@ -0,0 +1 @@
export { RoundedRockVoronoiBackground as StainedGlassGradientBackground } from "@/components/brand/RoundedRockVoronoiBackground";

View File

@ -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 (
<div
className={cn(
"wavy-contour-lines pointer-events-none absolute inset-0 z-0 overflow-hidden bg-white",
className
)}
aria-hidden
>
<div className={frameClass}>
<div className="wavy-contour-layer wavy-contour-layer--primary h-full w-full">
<svg
className="h-full w-full"
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg"
>
{CONTOUR_PRIMARY.map((d, i) => (
<path
key={`a-${i}`}
d={d}
fill="none"
stroke="rgba(26, 92, 56, 0.2)"
strokeWidth={0.34}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
</div>
<div className={frameClass}>
<div className="wavy-contour-layer wavy-contour-layer--secondary h-full w-full">
<svg
className="h-full w-full"
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg"
>
{CONTOUR_SECONDARY.map((d, i) => (
<path
key={`b-${i}`}
d={d}
fill="none"
stroke="rgba(26, 92, 56, 0.11)"
strokeWidth={0.26}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
</div>
</div>
);
}

View File

@ -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;
}

View File

@ -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 (
<section
id="hero"
data-section-hero
className="section-white relative isolate min-h-[min(100svh,900px)] overflow-x-hidden overflow-y-visible bg-white"
onMouseMove={handleHeroPointerMove}
onMouseLeave={handleHeroPointerLeave}
@ -78,8 +81,8 @@ export function Hero() {
/>
<TopoSectionProvider tone="light">
<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">
<TopoProseSurface className="flex w-full flex-col items-center text-center">
<div className="topo-content-layer topo-content-readable relative z-20 mx-auto flex min-h-[min(100svh,900px)] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center md:px-6">
<TopoProseSurface className="mx-auto flex w-full max-w-4xl flex-col items-center text-center">
<ScrollReveal immediate variant="fade-up" delay={0}>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[#1a5c38]/80 md:text-sm">
{site.dates.label} · {site.venue.address}
@ -128,6 +131,8 @@ export function Hero() {
</p>
</ScrollReveal>
</TopoProseSurface>
<LastYearWinnersScroll variant="on-light" className="w-full max-w-5xl" />
</div>
</TopoSectionProvider>
</section>

View File

@ -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 (
<div
className={cn(
"flex h-10 shrink-0 items-center gap-2 rounded-lg border border-white/25 bg-white/90 px-2.5 shadow-sm",
logoOnly && "px-2",
className
)}
title={company.name}
>
<div className="relative flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white">
{showImage ? (
<Image
src={src}
alt=""
width={28}
height={28}
className="size-7 object-contain p-0.5"
onError={() => setFailed(true)}
/>
) : company.initials ? (
<span className="text-[10px] font-bold leading-none text-[#1a5c38]">
{company.initials}
</span>
) : (
<Image
src={GRV_LOGO}
alt=""
width={28}
height={28}
className="size-7 object-contain p-0.5"
/>
)}
</div>
{company.name ? (
<span className="max-w-[7.5rem] truncate text-[11px] font-medium text-[#1a5c38]">
{company.name}
</span>
) : null}
</div>
);
}

View File

@ -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 (
<div
className={cn(
"pt-8",
onLight ? "mt-8 border-t border-[#1a5c38]/12" : "mt-10 border-t border-white/15",
className
)}
>
<p
className={cn(
"text-center text-[10px] font-semibold uppercase tracking-[0.2em] text-[#ffb300]"
)}
>
{lastYearWinnersCopy.eyebrow}
</p>
<p
className={cn(
"mt-1 text-center text-sm font-medium",
onLight ? "text-[#1a5c38]/80" : "text-white/85"
)}
>
{lastYearWinnersCopy.headline}
</p>
<div
className="relative mt-5 overflow-hidden"
aria-label="Companies supported at last year's summit"
>
<div
className={cn(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-10 bg-gradient-to-r to-transparent",
onLight ? "from-white" : "from-[#1a5c38]"
)}
/>
<div
className={cn(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-10 bg-gradient-to-l to-transparent",
onLight ? "from-white" : "from-[#1a5c38]"
)}
/>
<div className="marquee-winners flex w-max shrink-0 items-center gap-3 py-1">
{items.map((company, i) => (
<LastYearWinnerMark key={`${company.id}-${i}`} company={company} />
))}
</div>
</div>
</div>
);
}

View File

@ -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 (
<section className="section-white relative overflow-hidden border-y border-border bg-white py-8 text-[#0d3d26]">
<p className="mb-6 text-center text-xs font-semibold uppercase tracking-widest text-[#3d5248]">
<WavyContourLinesBackground className="z-0" />
<p className="relative z-10 mb-6 text-center text-xs font-semibold uppercase tracking-widest text-[#3d5248]">
With the support of
</p>
<div className="overflow-hidden">
<div className="relative z-10 overflow-hidden">
<div className="marquee flex shrink-0 items-center gap-8">
{[...slots, ...slots].map((i) => (
<PartnerLogoPlaceholder key={i} size="sm" className="shrink-0" />

View File

@ -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 (
<Section variant="muted" id="stats">

View File

@ -52,11 +52,11 @@ export function FooterNewsletter() {
}
return (
<div className="relative z-10 mx-auto max-w-5xl rounded-3xl border border-border bg-white p-6 shadow-2xl md:p-10">
<div className="relative z-10 mx-auto max-w-5xl rounded-3xl border border-[#1a5c38]/14 bg-white p-6 shadow-md shadow-[#1a5c38]/6 md:p-10">
<div className="grid gap-8 lg:grid-cols-[1fr_1.2fr] lg:items-start">
<div>
<BrandLogo href={undefined} />
<h2 className="mt-4 text-2xl font-bold md:text-3xl">Stay up to date!</h2>
<h2 className="mt-4 text-2xl font-bold text-[#0d3d26] md:text-3xl">Stay up to date!</h2>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
Get announcements about tickets, lineup, and the next Great Rift Valley Innovation
Summit edition before anyone else.

View File

@ -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 (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-2.88 2.5 2.89 2.89 0 0 1-2.89-2.89 2.89 2.89 0 0 1 2.89-2.89c.28 0 .54.04.79.1V9.01a6.27 6.27 0 0 0-.79-.05 6.34 6.34 0 0 0-6.34 6.34 6.34 6.34 0 0 0 6.34 6.34 6.34 6.34 0 0 0 6.33-6.34V8.69a8.18 8.18 0 0 0 4.77 1.52V6.76a4.85 4.85 0 0 1-1-.07z" />
</svg>
);
}
function LinkedInIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
<path d="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.35-1.85 3.58 0 4.24 2.36 4.24 5.43v6.31zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45zM22 0H2C.9 0 0 .9 0 2v20c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V2c0-1.1-.9-2-2-2z" />
</svg>
);
}
function FacebookIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
<path d="M24 12.07C24 5.41 18.63 0 12 0S0 5.41 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.41H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.27h3.32l-.53 3.49h-2.79V24C19.61 23.1 24 18.1 24 12.07z" />
</svg>
);
}
function InstagramIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
<path d="M12 2.16c3.2 0 3.58.01 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.43.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.43.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-1.17-.05-1.8-.25-2.23-.41-.56-.22-.96-.48-1.38-.9a3.37 3.37 0 0 1-.9-1.38c-.16-.43-.36-1.06-.41-2.23-.06-1.27-.07-1.65-.07-4.85s.01-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.43-.16 1.06-.36 2.23-.41 1.27-.06 1.65-.07 4.85-.07zM12 0C8.74 0 8.33.01 7.05.07 5.78.13 4.9.33 4.15.63c-.78.3-1.44.7-2.1 1.36A5.47 5.47 0 0 0 .63 4.15C.33 4.9.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.06 1.27.26 2.15.56 2.9.3.75.7 1.41 1.36 2.07.66.66 1.32 1.06 2.07 1.36.75.3 1.63.5 2.9.56C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c1.27-.06 2.15-.26 2.9-.56a5.47 5.47 0 0 0 2.07-1.36 5.47 5.47 0 0 0 1.36-2.07c.3-.75.5-1.63.56-2.9.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.06-1.27-.26-2.15-.56-2.9a5.47 5.47 0 0 0-1.36-2.07A5.47 5.47 0 0 0 19.85.63c-.75-.3-1.63-.5-2.9-.56C15.67.01 15.26 0 12 0zm0 5.84a6.16 6.16 0 1 0 0 12.32 6.16 6.16 0 0 0 0-12.32zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.41-11.85a1.44 1.44 0 1 0 0 2.88 1.44 1.44 0 0 0 0-2.88z" />
</svg>
);
}
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 (
<div className={cn("flex flex-wrap items-center gap-3", className)}>
<span className="sr-only">Follow GRV Summit</span>
{SOCIAL.map(({ id, label, href, Icon }) => (
<Link
key={id}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="inline-flex size-10 items-center justify-center rounded-full border border-[#1a5c38]/18 bg-[#f7faf8] text-[#1a5c38] shadow-sm transition-colors hover:border-[#1a5c38]/35 hover:bg-[#e8f2ec] hover:text-[#0d3d26]"
>
<Icon className="size-[18px]" />
</Link>
))}
</div>
);
}

View File

@ -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 (
<div
className={cn(
"rounded-2xl border border-[#1a5c38]/14 bg-white p-6 shadow-md shadow-[#1a5c38]/6 md:p-8",
className
)}
>
{children}
</div>
);
}

View File

@ -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
)}
>
<WavyContourLinesBackground className="z-0" />
<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">

View File

@ -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
) : (
<RoundedRockVoronoiBackground
className="z-0"
gradient={variant === "inverse"}
/>
))}
{!isGreen &&
(background !== undefined ? (
background
) : (
<WavyContourLinesBackground className="z-0" />
))}
{!isGreen && <TopoCurvyExtend />}
<TopoSectionProvider tone={tone}>
<ScrollReveal

View File

@ -1,6 +1,8 @@
import Link from "next/link";
import { FooterTopoPattern } from "@/components/brand/FooterTopoPattern";
import { GeometricMessBackground } from "@/components/brand/GeometricMessBackground";
import { FooterNewsletter } from "@/components/layout/FooterNewsletter";
import { FooterSocialLinks } from "@/components/layout/FooterSocialLinks";
import { FooterSurface } from "@/components/layout/FooterSurface";
import { site } from "@/content/site";
const footerColumns = [
@ -44,46 +46,57 @@ const footerColumns = [
export function SiteFooter() {
return (
<footer className="relative mt-24 overflow-hidden bg-[#1a5c38] text-white">
<FooterTopoPattern />
<footer className="site-footer section-white relative overflow-hidden bg-white pt-8 text-[#0d3d26] md:pt-10">
<GeometricMessBackground />
<div className="relative z-10 px-4 pb-4 md:px-6">
<div className="relative z-10 space-y-6 px-4 pb-12 md:space-y-8 md:px-6">
<FooterNewsletter />
</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="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
{footerColumns.map((col) => (
<div key={col.title}>
<h3 className="text-sm font-semibold text-white">{col.title}</h3>
<ul className="mt-4 space-y-2.5">
{col.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-white/75 transition-colors hover:text-white"
{...("external" in link && link.external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
{link.label}
</Link>
</li>
))}
</ul>
<div className="mx-auto max-w-6xl">
<FooterSurface>
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
{footerColumns.map((col) => (
<div key={col.title}>
<h3 className="text-sm font-semibold text-[#1a5c38]">{col.title}</h3>
<ul className="mt-4 space-y-2.5">
{col.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-[#3d5248] transition-colors hover:text-[#0d3d26]"
{...("external" in link && link.external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
<div className="mt-14 border-t border-white/15 pt-8">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-center text-xs font-medium uppercase tracking-wider text-white/80 sm:text-left">
{site.shortName} · {site.dates.label} · Presented by {site.presentedBy}
</p>
<p className="text-center text-xs text-white/60 sm:text-right">
© {new Date().getFullYear()} Ethiopian Diaspora Trust Fund. All rights reserved.
</p>
</div>
<div className="mt-10 flex flex-col gap-6 border-t border-[#1a5c38]/12 pt-8 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]">
Follow us
</p>
<FooterSocialLinks className="mt-4" />
</div>
</div>
</FooterSurface>
<FooterSurface className="mt-6 py-5 md:py-6">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-center text-xs font-medium uppercase tracking-wider text-[#3d5248] sm:text-left">
{site.shortName} · {site.dates.label} · Presented by {site.presentedBy}
</p>
<p className="text-center text-xs text-[#5c6b62] sm:text-right">
© {new Date().getFullYear()} Ethiopian Diaspora Trust Fund. All rights reserved.
</p>
</div>
</FooterSurface>
</div>
</div>
</footer>

View File

@ -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"
>
<RoundedRockVoronoiBackground className="z-0" />
<TopoSectionProvider tone="green">
<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">

View File

@ -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];

View File

@ -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" },

61
lib/footer-curve-edge.ts Normal file
View File

@ -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%)`;
}

213
lib/voronoi-mesh.ts Normal file
View File

@ -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;
}

38
lib/wavy-contour-lines.ts Normal file
View File

@ -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;
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

View File

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