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; background-color: #1a5c38;
color: #fafafa; 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 { .grain {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
} }
/* 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) */ /* Full-page atmosphere — brand wash drift (behind grain + lines) */
@keyframes backdrop-bloom-a { @keyframes backdrop-bloom-a {
0%, 0%,
@ -157,6 +203,9 @@
.marquee { .marquee {
animation: marquee 40s linear infinite; animation: marquee 40s linear infinite;
} }
.marquee-winners {
animation: marquee 55s linear infinite;
}
@keyframes marquee { @keyframes marquee {
from { from {
transform: translateX(0); transform: translateX(0);
@ -166,7 +215,8 @@
} }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.marquee { .marquee,
.marquee-winners {
animation: none; animation: none;
} }
.backdrop-bloom-a, .backdrop-bloom-a,
@ -523,11 +573,22 @@
z-index: 10; 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 */ /* Readable brand-green copy on all white sections */
.section-white { .section-white {
color: #0d3d26; 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-layer,
.section-white .topo-content-readable, .section-white .topo-content-readable,
.section-white .topo-content-layer :is(h1, h2, h3, h4, h5, h6, p, li, label, summary), .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) { @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-floor-glow,
.rift-hero-intro .rift-channel-open, .rift-hero-intro .rift-channel-open,
.rift-hero-intro .rift-contour-path, .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"; import { cn } from "@/lib/utils";
type Props = { type Props = {
className?: string; 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) { export function FooterTopoPattern({ className }: Props) {
return ( return (
<div <div
className={cn( 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 className
)} )}
aria-hidden aria-hidden
> >
<div <svg
className="absolute inset-x-0 bottom-0 h-full" className="absolute left-1/2 top-1/2 h-[135%] w-[135%] -translate-x-1/2 -translate-y-1/2 -rotate-[14deg] scale-110"
style={{ viewBox="0 0 100 100"
maskImage: preserveAspectRatio="none"
"linear-gradient(to top, black 0%, black 38%, rgba(0,0,0,0.55) 62%, transparent 100%)", xmlns="http://www.w3.org/2000/svg"
WebkitMaskImage:
"linear-gradient(to top, black 0%, black 38%, rgba(0,0,0,0.55) 62%, transparent 100%)",
}}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {FOOTER_MESH.map((cell, i) => (
<img <path
src={MAIN_WHITE_PATTERN_SRC} key={i}
alt="" d={cell.d}
className="topo-footer-pattern-asset block h-full w-full object-cover object-bottom" fill={cell.fill}
draggable={false} 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>
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-[#1a5c38] to-transparent" />
</div> </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 { ArrowRight } from "lucide-react";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { HeroGrantLine } from "@/components/home/HeroGrantLine"; import { HeroGrantLine } from "@/components/home/HeroGrantLine";
import { LastYearWinnersScroll } from "@/components/home/LastYearWinnersScroll";
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend"; import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
import { HeroTopographyBackground } from "@/components/home/HeroTopographyBackground"; import { HeroTopographyBackground } from "@/components/home/HeroTopographyBackground";
import { HeroRiftParticles } from "@/components/home/HeroRiftParticles"; import { HeroRiftParticles } from "@/components/home/HeroRiftParticles";
@ -60,6 +61,8 @@ export function Hero() {
return ( return (
<section <section
id="hero"
data-section-hero
className="section-white relative isolate min-h-[min(100svh,900px)] overflow-x-hidden overflow-y-visible bg-white" className="section-white relative isolate min-h-[min(100svh,900px)] overflow-x-hidden overflow-y-visible bg-white"
onMouseMove={handleHeroPointerMove} onMouseMove={handleHeroPointerMove}
onMouseLeave={handleHeroPointerLeave} onMouseLeave={handleHeroPointerLeave}
@ -78,8 +81,8 @@ export function Hero() {
/> />
<TopoSectionProvider tone="light"> <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"> <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="flex w-full flex-col items-center text-center"> <TopoProseSurface className="mx-auto flex w-full max-w-4xl flex-col items-center text-center">
<ScrollReveal immediate variant="fade-up" delay={0}> <ScrollReveal immediate variant="fade-up" delay={0}>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[#1a5c38]/80 md:text-sm"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[#1a5c38]/80 md:text-sm">
{site.dates.label} · {site.venue.address} {site.dates.label} · {site.venue.address}
@ -128,6 +131,8 @@ export function Hero() {
</p> </p>
</ScrollReveal> </ScrollReveal>
</TopoProseSurface> </TopoProseSurface>
<LastYearWinnersScroll variant="on-light" className="w-full max-w-5xl" />
</div> </div>
</TopoSectionProvider> </TopoSectionProvider>
</section> </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 { PartnerLogoPlaceholder } from "@/components/brand/PartnerLogoPlaceholder";
import { WavyContourLinesBackground } from "@/components/brand/WavyContourLinesBackground";
export function PartnerMarquee() { export function PartnerMarquee() {
const slots = Array.from({ length: 8 }, (_, i) => i); const slots = Array.from({ length: 8 }, (_, i) => i);
return ( return (
<section className="section-white relative overflow-hidden border-y border-border bg-white py-8 text-[#0d3d26]"> <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 With the support of
</p> </p>
<div className="overflow-hidden"> <div className="relative z-10 overflow-hidden">
<div className="marquee flex shrink-0 items-center gap-8"> <div className="marquee flex shrink-0 items-center gap-8">
{[...slots, ...slots].map((i) => ( {[...slots, ...slots].map((i) => (
<PartnerLogoPlaceholder key={i} size="sm" className="shrink-0" /> <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 { Section } from "@/components/layout/Section";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface"; import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount"; import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
export function StatsGrid() { export function StatsGrid() {
return ( return (
<Section variant="muted" id="stats"> <Section variant="muted" id="stats">

View File

@ -52,11 +52,11 @@ export function FooterNewsletter() {
} }
return ( 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 className="grid gap-8 lg:grid-cols-[1fr_1.2fr] lg:items-start">
<div> <div>
<BrandLogo href={undefined} /> <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"> <p className="mt-2 text-sm text-muted-foreground leading-relaxed">
Get announcements about tickets, lineup, and the next Great Rift Valley Innovation Get announcements about tickets, lineup, and the next Great Rift Valley Innovation
Summit edition before anyone else. 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 type { ReactNode } from "react";
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend"; import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
import { WavyContourLinesBackground } from "@/components/brand/WavyContourLinesBackground";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface"; import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext"; import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -43,6 +44,7 @@ export function PageRiftHeader({
className className
)} )}
> >
<WavyContourLinesBackground className="z-0" />
<TopoCurvyExtend /> <TopoCurvyExtend />
<TopoSectionProvider tone="light"> <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"> <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 type { ReactNode } from "react";
import { RoundedRockVoronoiBackground } from "@/components/brand/RoundedRockVoronoiBackground";
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend"; import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
import { WavyContourLinesBackground } from "@/components/brand/WavyContourLinesBackground";
import { ScrollReveal } from "@/components/motion/ScrollReveal"; import { ScrollReveal } from "@/components/motion/ScrollReveal";
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext"; import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
import { toneFromSectionVariant, type TopoPatternId } from "@/content/topo-patterns"; import { toneFromSectionVariant, type TopoPatternId } from "@/content/topo-patterns";
@ -9,6 +11,8 @@ type Props = {
id?: string; id?: string;
className?: string; className?: string;
children: ReactNode; children: ReactNode;
/** Override section backdrop (rock on green, contours on white); pass `null` to hide. */
background?: ReactNode | null;
variant?: "default" | "muted" | "inverse"; variant?: "default" | "muted" | "inverse";
/** @deprecated Patterns are hero + footer only; kept for API compatibility */ /** @deprecated Patterns are hero + footer only; kept for API compatibility */
riftPattern?: TopoPatternId; riftPattern?: TopoPatternId;
@ -20,6 +24,7 @@ export function Section({
id, id,
className, className,
children, children,
background,
variant = "default", variant = "default",
riftPattern, riftPattern,
riftFlow, riftFlow,
@ -42,6 +47,21 @@ export function Section({
)} )}
data-section-tone={tone} 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 />} {!isGreen && <TopoCurvyExtend />}
<TopoSectionProvider tone={tone}> <TopoSectionProvider tone={tone}>
<ScrollReveal <ScrollReveal

View File

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

View File

@ -1,3 +1,4 @@
import { RoundedRockVoronoiBackground } from "@/components/brand/RoundedRockVoronoiBackground";
import { partnershipCta } from "@/content/partners"; import { partnershipCta } from "@/content/partners";
import { TopoProseSurface } from "@/components/layout/TopoProseSurface"; import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext"; import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
@ -10,6 +11,7 @@ export function PartnershipCtaBand() {
id="partnership-form" id="partnership-form"
className="group/topo-section section-green relative isolate overflow-hidden bg-[#1a5c38] py-16 md:py-24" className="group/topo-section section-green relative isolate overflow-hidden bg-[#1a5c38] py-16 md:py-24"
> >
<RoundedRockVoronoiBackground className="z-0" />
<TopoSectionProvider tone="green"> <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"> <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"> <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/", legacySite: "https://grvsummit.com/",
calendarIcs: "/calendar", 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: [ stats: [
{ type: "static", value: "500+", label: "Attendees" }, { type: "static", value: "500+", label: "Attendees" },
{ type: "cycling", label: "Grant funding" }, { 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/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/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: `${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) { async function download(url, dest) {