Enhance footer and hero with brand backgrounds
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
This commit is contained in:
parent
2b419883eb
commit
d261148b35
26
.git-push-log.sh
Normal file
26
.git-push-log.sh
Normal 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"
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
</div>
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-[#1a5c38] to-transparent" />
|
))}
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gradient-to-t from-[#0d3d26]/35 via-transparent to-transparent"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
components/brand/GeometricMessBackground.tsx
Normal file
108
components/brand/GeometricMessBackground.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
components/brand/LowPolyGeometricBackground.tsx
Normal file
1
components/brand/LowPolyGeometricBackground.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { RoundedRockVoronoiBackground as LowPolyGeometricBackground } from "@/components/brand/RoundedRockVoronoiBackground";
|
||||||
95
components/brand/RoundedRockVoronoiBackground.tsx
Normal file
95
components/brand/RoundedRockVoronoiBackground.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
components/brand/StainedGlassGradientBackground.tsx
Normal file
1
components/brand/StainedGlassGradientBackground.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { RoundedRockVoronoiBackground as StainedGlassGradientBackground } from "@/components/brand/RoundedRockVoronoiBackground";
|
||||||
78
components/brand/WavyContourLinesBackground.tsx
Normal file
78
components/brand/WavyContourLinesBackground.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
components/brand/WavyTessellationMesh.tsx
Normal file
94
components/brand/WavyTessellationMesh.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
61
components/home/LastYearWinnerMark.tsx
Normal file
61
components/home/LastYearWinnerMark.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
components/home/LastYearWinnersScroll.tsx
Normal file
62
components/home/LastYearWinnersScroll.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
68
components/layout/FooterSocialLinks.tsx
Normal file
68
components/layout/FooterSocialLinks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/layout/FooterSurface.tsx
Normal file
21
components/layout/FooterSurface.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,46 +46,57 @@ 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">
|
||||||
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
<FooterSurface>
|
||||||
{footerColumns.map((col) => (
|
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div key={col.title}>
|
{footerColumns.map((col) => (
|
||||||
<h3 className="text-sm font-semibold text-white">{col.title}</h3>
|
<div key={col.title}>
|
||||||
<ul className="mt-4 space-y-2.5">
|
<h3 className="text-sm font-semibold text-[#1a5c38]">{col.title}</h3>
|
||||||
{col.links.map((link) => (
|
<ul className="mt-4 space-y-2.5">
|
||||||
<li key={link.href}>
|
{col.links.map((link) => (
|
||||||
<Link
|
<li key={link.href}>
|
||||||
href={link.href}
|
<Link
|
||||||
className="text-sm text-white/75 transition-colors hover:text-white"
|
href={link.href}
|
||||||
{...("external" in link && link.external
|
className="text-sm text-[#3d5248] transition-colors hover:text-[#0d3d26]"
|
||||||
? { target: "_blank", rel: "noopener noreferrer" }
|
{...("external" in link && link.external
|
||||||
: {})}
|
? { target: "_blank", rel: "noopener noreferrer" }
|
||||||
>
|
: {})}
|
||||||
{link.label}
|
>
|
||||||
</Link>
|
{link.label}
|
||||||
</li>
|
</Link>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</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 className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
<div>
|
||||||
<p className="text-center text-xs font-medium uppercase tracking-wider text-white/80 sm:text-left">
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]">
|
||||||
{site.shortName} · {site.dates.label} · Presented by {site.presentedBy}
|
Follow us
|
||||||
</p>
|
</p>
|
||||||
<p className="text-center text-xs text-white/60 sm:text-right">
|
<FooterSocialLinks className="mt-4" />
|
||||||
© {new Date().getFullYear()} Ethiopian Diaspora Trust Fund. All rights reserved.
|
</div>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
47
content/last-year-winners.ts
Normal file
47
content/last-year-winners.ts
Normal 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];
|
||||||
|
|
@ -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
61
lib/footer-curve-edge.ts
Normal 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
213
lib/voronoi-mesh.ts
Normal 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
38
lib/wavy-contour-lines.ts
Normal 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;
|
||||||
|
}
|
||||||
0
public/branding/winners/.gitkeep
Normal file
0
public/branding/winners/.gitkeep
Normal file
BIN
public/branding/winners/globedock-academy.png
Normal file
BIN
public/branding/winners/globedock-academy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/branding/winners/lifeline-addis.png
Normal file
BIN
public/branding/winners/lifeline-addis.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/branding/winners/muyalogy.png
Normal file
BIN
public/branding/winners/muyalogy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 814 B |
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user