feat: real people photos in testimonials + distinct sector case images, optimized webp; cleanup unused PNGs
|
|
@ -1,19 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CASE CARD — replaces the rejected repetitive photos with a coded, animated
|
* CASE CARD — distinct per case, photo-led with a tailored data viz.
|
||||||
* data visual unique per case:
|
* - Each card leads with its real SECTOR PHOTO (next/image -> WebP/AVIF, lazy,
|
||||||
* - 3D tilt that tracks the pointer via a SOFT spring (momentum => the card
|
* reserved aspect-ratio so no CLS). The image scales + parallax-drifts toward
|
||||||
* floats and settles, never snaps). Transform-only.
|
* the pointer on hover.
|
||||||
* - A small lift + accent shadow on hover; a press dip (scale 0.985) so the
|
* - Below the photo, a data visual UNIQUE to the case story:
|
||||||
* whole card shares the page's tactile press language.
|
* dual -> CPA-down + ROAS-up twin lines (fashion)
|
||||||
* - An animated bars + sparkline "result chart" (CSS + GSAP DrawSVG on the
|
* ramp -> a slow-then-steep demo-requests line (SaaS)
|
||||||
* spark) growing into view — no two cards look alike.
|
* bookings -> monthly booking bars climbing toward a capacity target (clinic)
|
||||||
* - A glare/sheen that follows the cursor across the surface.
|
* The viz animates in on view (GSAP DrawSVG / CSS bar grow); no two alike.
|
||||||
* - The chart panel gets a clip-path inset() wipe on first view (premium).
|
* - 3D tilt that tracks the pointer via a soft spring; lift + accent shadow on
|
||||||
* Reduced-motion / touch: flat card, bars still grow on view via CSS.
|
* hover; press dip. Transform-only.
|
||||||
|
* Reduced-motion / touch: flat card, static image, viz renders fully drawn.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
motion,
|
motion,
|
||||||
useMotionValue,
|
useMotionValue,
|
||||||
|
|
@ -26,15 +28,141 @@ import { SPRING, EASE_OUT } from "./motion";
|
||||||
|
|
||||||
export type CaseData = {
|
export type CaseData = {
|
||||||
tag: string;
|
tag: string;
|
||||||
|
image: string;
|
||||||
|
alt: string;
|
||||||
problem: string;
|
problem: string;
|
||||||
result: string;
|
result: string;
|
||||||
how: string;
|
how: string;
|
||||||
metricNum: string;
|
metricNum: string;
|
||||||
metricLabel: string;
|
metricLabel: string;
|
||||||
bars: number[]; // 0..100 heights — unique per case
|
viz: "dual" | "ramp" | "bookings";
|
||||||
accent: string; // brand accent for this card
|
series: number[];
|
||||||
|
series2?: number[];
|
||||||
|
target?: number;
|
||||||
|
accent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---- viz geometry helpers (0..100 series mapped into a 240x96 box) ---- */
|
||||||
|
const VW = 240;
|
||||||
|
const VH = 96;
|
||||||
|
const VP = 10;
|
||||||
|
function toPoints(series: number[]) {
|
||||||
|
const n = series.length;
|
||||||
|
return series.map((v, i) => {
|
||||||
|
const x = VP + (i / (n - 1)) * (VW - VP * 2);
|
||||||
|
const y = VP + (1 - v / 100) * (VH - VP * 2);
|
||||||
|
return { x, y };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const poly = (series: number[]) =>
|
||||||
|
toPoints(series)
|
||||||
|
.map((p) => `${p.x},${p.y}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
function CaseViz({ data }: { data: CaseData }) {
|
||||||
|
const accent = data.accent;
|
||||||
|
|
||||||
|
if (data.viz === "dual") {
|
||||||
|
// CPA (falling, dashed) + ROAS (rising, solid) twin lines
|
||||||
|
return (
|
||||||
|
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
|
||||||
|
<polyline
|
||||||
|
className="case__line case__line--muted"
|
||||||
|
points={poly(data.series)}
|
||||||
|
fill="none"
|
||||||
|
stroke="#9a9aac"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
className="case__line case__line--accent"
|
||||||
|
points={poly(data.series2 ?? data.series)}
|
||||||
|
fill="none"
|
||||||
|
stroke={accent}
|
||||||
|
strokeWidth="2.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<g className="case__legend" fill="currentColor">
|
||||||
|
<text x={VP} y={VH - 1}>CPA ↓</text>
|
||||||
|
<text x={VW - VP} y={VH - 1} textAnchor="end" fill={accent}>ROAS ↑</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.viz === "bookings") {
|
||||||
|
// monthly bookings bars climbing toward a capacity target line
|
||||||
|
const n = data.series.length;
|
||||||
|
const gap = 6;
|
||||||
|
const bw = (VW - VP * 2 - gap * (n - 1)) / n;
|
||||||
|
const ty = VP + (1 - (data.target ?? 90) / 100) * (VH - VP * 2);
|
||||||
|
return (
|
||||||
|
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
|
||||||
|
{/* target / capacity line */}
|
||||||
|
<line
|
||||||
|
className="case__target"
|
||||||
|
x1={VP}
|
||||||
|
y1={ty}
|
||||||
|
x2={VW - VP}
|
||||||
|
y2={ty}
|
||||||
|
stroke={accent}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeDasharray="3 4"
|
||||||
|
/>
|
||||||
|
<text className="case__targetlab" x={VW - VP} y={ty - 4} textAnchor="end" fill={accent}>
|
||||||
|
capacity
|
||||||
|
</text>
|
||||||
|
{data.series.map((v, i) => {
|
||||||
|
const h = (v / 100) * (VH - VP * 2);
|
||||||
|
const x = VP + i * (bw + gap);
|
||||||
|
const y = VH - VP - h;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
className="case__bar2"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={bw}
|
||||||
|
height={h}
|
||||||
|
rx={2}
|
||||||
|
fill={accent}
|
||||||
|
style={{ ["--d" as string]: `${i * 80}ms` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ramp — slow-then-steep demo-requests line with an area fill
|
||||||
|
const pts = toPoints(data.series);
|
||||||
|
const linePts = poly(data.series);
|
||||||
|
const area = `${linePts} ${VW - VP},${VH - VP} ${VP},${VH - VP}`;
|
||||||
|
return (
|
||||||
|
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`rampFill-${accent.slice(1)}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={accent} stopOpacity="0.32" />
|
||||||
|
<stop offset="100%" stopColor={accent} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon className="case__area" points={area} fill={`url(#rampFill-${accent.slice(1)})`} />
|
||||||
|
<polyline
|
||||||
|
className="case__line case__line--accent"
|
||||||
|
points={linePts}
|
||||||
|
fill="none"
|
||||||
|
stroke={accent}
|
||||||
|
strokeWidth="2.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle className="case__rampdot" cx={pts[pts.length - 1].x} cy={pts[pts.length - 1].y} r="3.5" fill={accent} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CaseCard({
|
export default function CaseCard({
|
||||||
data,
|
data,
|
||||||
index,
|
index,
|
||||||
|
|
@ -46,13 +174,11 @@ export default function CaseCard({
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const mx = useMotionValue(0.5);
|
const mx = useMotionValue(0.5);
|
||||||
const my = useMotionValue(0.5);
|
const my = useMotionValue(0.5);
|
||||||
const rx = useSpring(useTransform(my, [0, 1], [6, -6]), SPRING.tilt);
|
const rx = useSpring(useTransform(my, [0, 1], [5, -5]), SPRING.tilt);
|
||||||
const ry = useSpring(useTransform(mx, [0, 1], [-8, 8]), SPRING.tilt);
|
const ry = useSpring(useTransform(mx, [0, 1], [-6, 6]), SPRING.tilt);
|
||||||
const glare = useTransform(
|
// image parallax — drifts opposite the tilt for depth
|
||||||
[mx, my],
|
const imgX = useSpring(useTransform(mx, [0, 1], [10, -10]), SPRING.tilt);
|
||||||
([gx, gy]: number[]) =>
|
const imgY = useSpring(useTransform(my, [0, 1], [10, -10]), SPRING.tilt);
|
||||||
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.16), transparent 45%)`
|
|
||||||
);
|
|
||||||
|
|
||||||
const onMove = (e: React.PointerEvent) => {
|
const onMove = (e: React.PointerEvent) => {
|
||||||
if (reduce || e.pointerType !== "mouse") return;
|
if (reduce || e.pointerType !== "mouse") return;
|
||||||
|
|
@ -67,18 +193,27 @@ export default function CaseCard({
|
||||||
my.set(0.5);
|
my.set(0.5);
|
||||||
};
|
};
|
||||||
|
|
||||||
// DrawSVG the sparkline once the card scrolls in — a small, deliberate detail.
|
// animate the viz in on view (lines draw, bars grow)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el || reduce) return;
|
if (!el || reduce) return;
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
gsap.from(el.querySelector(".case__spark polyline"), {
|
const lines = el.querySelectorAll(".case__line, .case__area, .case__target");
|
||||||
|
gsap.from(lines, {
|
||||||
drawSVG: "0%",
|
drawSVG: "0%",
|
||||||
duration: 1.1,
|
duration: 1.1,
|
||||||
ease: "power2.out",
|
ease: "power2.out",
|
||||||
|
stagger: 0.12,
|
||||||
scrollTrigger: { trigger: el, start: "top 82%", once: true },
|
scrollTrigger: { trigger: el, start: "top 82%", once: true },
|
||||||
delay: 0.15 + index * 0.05,
|
delay: 0.15 + index * 0.05,
|
||||||
});
|
});
|
||||||
|
gsap.from(el.querySelectorAll(".case__rampdot, .case__targetlab, .case__legend"), {
|
||||||
|
autoAlpha: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power2.out",
|
||||||
|
scrollTrigger: { trigger: el, start: "top 82%", once: true },
|
||||||
|
delay: 0.7 + index * 0.05,
|
||||||
|
});
|
||||||
}, el);
|
}, el);
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
}, [reduce, index]);
|
}, [reduce, index]);
|
||||||
|
|
@ -104,42 +239,32 @@ export default function CaseCard({
|
||||||
className="case__inner"
|
className="case__inner"
|
||||||
style={{ ["--case-accent" as string]: data.accent }}
|
style={{ ["--case-accent" as string]: data.accent }}
|
||||||
>
|
>
|
||||||
<div className="case__head">
|
{/* sector photo — leads the card, parallax/scale on hover */}
|
||||||
<span className="case__no">{String(index + 1).padStart(2, "0")}</span>
|
<div className="case__photo">
|
||||||
<span className="case__tag">{data.tag}</span>
|
<motion.div
|
||||||
|
className="case__photo-inner"
|
||||||
|
style={reduce ? undefined : { x: imgX, y: imgY }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={data.image}
|
||||||
|
alt={data.alt}
|
||||||
|
width={900}
|
||||||
|
height={672}
|
||||||
|
sizes="(max-width: 980px) 90vw, 30vw"
|
||||||
|
loading="lazy"
|
||||||
|
className="case__img"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
<span className="case__photo-tag">{data.tag}</span>
|
||||||
|
<span className="case__photo-no" aria-hidden="true">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* coded data visual — unique bars per case, clip-wiped on first view */}
|
{/* per-case data visual */}
|
||||||
<motion.div
|
<div className="case__viz" aria-hidden="true">
|
||||||
className="case__viz"
|
<CaseViz data={data} />
|
||||||
aria-hidden="true"
|
|
||||||
initial={reduce ? { opacity: 1 } : { clipPath: "inset(0 0 100% 0)" }}
|
|
||||||
whileInView={{ clipPath: "inset(0 0 0% 0)" }}
|
|
||||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
|
||||||
transition={{ duration: 0.9, delay: index * 0.08 + 0.1, ease: EASE_OUT }}
|
|
||||||
>
|
|
||||||
<div className="case__bars">
|
|
||||||
{data.bars.map((h, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="case__bar"
|
|
||||||
style={{
|
|
||||||
["--h" as string]: `${h}%`,
|
|
||||||
["--d" as string]: `${i * 70}ms`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<svg className="case__spark" viewBox="0 0 120 40" preserveAspectRatio="none">
|
|
||||||
<polyline
|
|
||||||
points="0,34 24,30 48,22 72,18 96,8 120,4"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--case-accent)"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="case__body">
|
<div className="case__body">
|
||||||
<p className="case__problem">
|
<p className="case__problem">
|
||||||
|
|
@ -154,14 +279,6 @@ export default function CaseCard({
|
||||||
<span className="case__metric-num display">{data.metricNum}</span>
|
<span className="case__metric-num display">{data.metricNum}</span>
|
||||||
<span className="case__metric-lab">{data.metricLabel}</span>
|
<span className="case__metric-lab">{data.metricLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!reduce && (
|
|
||||||
<motion.span
|
|
||||||
className="case__glare"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ background: glare }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
134
app/components/FinalCTA.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FINAL CTA — the closing convincer. Deliberately MORE dramatic than the hero:
|
||||||
|
* - A big "cta" FluidBackground (brighter/faster cursor-reactive WebGL) fills
|
||||||
|
* the whole section behind the content.
|
||||||
|
* - KINETIC TYPE: the headline splits to chars that rise from a mask on view
|
||||||
|
* (GSAP), and "grow?" keeps the brand gradient. Per-char pointer parallax —
|
||||||
|
* each character leans toward the cursor with spring momentum, so the words
|
||||||
|
* feel physically alive as you move across them.
|
||||||
|
* - The primary button is magnetic (already) and gains an amplified glow here.
|
||||||
|
* Reduced-motion / no-JS: type is fully visible and static; CSS gradient stands
|
||||||
|
* in for the WebGL field.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { gsap, SplitText } from "./gsap";
|
||||||
|
import Magnetic from "./Magnetic";
|
||||||
|
import FluidBackground from "./FluidBackground";
|
||||||
|
import { SITE } from "../content";
|
||||||
|
|
||||||
|
export default function FinalCTA() {
|
||||||
|
const root = useRef<HTMLElement>(null);
|
||||||
|
const headRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = root.current;
|
||||||
|
const head = headRef.current;
|
||||||
|
if (!el || !head) return;
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduce) return;
|
||||||
|
|
||||||
|
// Capture the gradient word's length BEFORE splitting — SplitText empties
|
||||||
|
// the original <span class="grad">, so we must read it first.
|
||||||
|
const gradLen = (head.querySelector(".grad")?.textContent ?? "")
|
||||||
|
.replace(/\s/g, "").length;
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const split = new SplitText(head, {
|
||||||
|
type: "lines,chars",
|
||||||
|
linesClass: "final__line",
|
||||||
|
charsClass: "final__char",
|
||||||
|
});
|
||||||
|
gsap.set(head, { autoAlpha: 1 });
|
||||||
|
const chars = split.chars as HTMLElement[];
|
||||||
|
// Re-establish the brand gradient on the chars of the gradient word.
|
||||||
|
// After SplitText restructures into lines, the original .grad span is
|
||||||
|
// emptied and its chars become plain divs that inherit
|
||||||
|
// `-webkit-text-fill-color:transparent` WITHOUT a background (=> invisible).
|
||||||
|
// The gradient word is the trailing word, so the last `gradLen` chars get
|
||||||
|
// the .final__char--grad class which repaints each glyph with the gradient.
|
||||||
|
if (gradLen > 0) {
|
||||||
|
chars.slice(chars.length - gradLen).forEach((c) => {
|
||||||
|
c.classList.add("final__char--grad");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// chars rise from behind the line mask on view; the trigger fires as the
|
||||||
|
// section enters. `once` + the natural (visible) resting state guarantee
|
||||||
|
// the headline ends fully shown.
|
||||||
|
gsap.from(chars, {
|
||||||
|
yPercent: 120,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.7,
|
||||||
|
ease: "emilOut",
|
||||||
|
stagger: 0.02,
|
||||||
|
scrollTrigger: { trigger: el, start: "top 85%", once: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// per-char pointer parallax: each char leans toward the cursor.
|
||||||
|
const qx = chars.map((c) => gsap.quickTo(c, "x", { duration: 0.6, ease: "power3.out" }));
|
||||||
|
const qy = chars.map((c) => gsap.quickTo(c, "y", { duration: 0.6, ease: "power3.out" }));
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const cx = e.clientX;
|
||||||
|
const cy = e.clientY;
|
||||||
|
chars.forEach((c, i) => {
|
||||||
|
const cr = c.getBoundingClientRect();
|
||||||
|
const dx = cx - (cr.left + cr.width / 2);
|
||||||
|
const dy = cy - (cr.top + cr.height / 2);
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const pull = Math.max(0, 1 - dist / 420);
|
||||||
|
qx[i](dx * 0.06 * pull);
|
||||||
|
qy[i](dy * 0.06 * pull);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onLeave = () => chars.forEach((_, i) => { qx[i](0); qy[i](0); });
|
||||||
|
el.addEventListener("pointermove", onMove);
|
||||||
|
el.addEventListener("pointerleave", onLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("pointermove", onMove);
|
||||||
|
el.removeEventListener("pointerleave", onLeave);
|
||||||
|
};
|
||||||
|
}, el);
|
||||||
|
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={root} className="final frame" aria-labelledby="final-h">
|
||||||
|
{/* dramatic cursor-reactive WebGL field — the closing moment */}
|
||||||
|
<FluidBackground variant="cta" />
|
||||||
|
|
||||||
|
<div className="wrap final__wrap">
|
||||||
|
<p className="kicker">
|
||||||
|
<span className="kicker__dot" />
|
||||||
|
The bottom line
|
||||||
|
</p>
|
||||||
|
<h2 ref={headRef} id="final-h" className="display final__h">
|
||||||
|
Ready to <span className="grad">grow?</span>
|
||||||
|
</h2>
|
||||||
|
<p className="final__sub">
|
||||||
|
No long contracts. No vanity reports. Marketing you can measure in
|
||||||
|
sales.
|
||||||
|
</p>
|
||||||
|
<div className="final__cta">
|
||||||
|
<Magnetic strength={0.45}>
|
||||||
|
<Link
|
||||||
|
href={SITE.booking}
|
||||||
|
className="btn btn--accent btn--xl"
|
||||||
|
data-cursor="Book a call"
|
||||||
|
>
|
||||||
|
Book a call
|
||||||
|
</Link>
|
||||||
|
</Magnetic>
|
||||||
|
<a href={`mailto:${SITE.email}`} className="btn btn--ghost">
|
||||||
|
or {SITE.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
app/components/FluidBackground.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FLUID BACKGROUND — a live, cursor-reactive WebGL field (ogl) that sits BEHIND
|
||||||
|
* the content. A full-screen fragment shader paints a flowing iridescent
|
||||||
|
* metaball / fluid mesh in the brand palette (blue -> violet -> emerald). The
|
||||||
|
* field bends, glows and FLOWS toward the pointer: a spring-smoothed pointer
|
||||||
|
* position warps the domain-warped noise and injects a bright, additive glow
|
||||||
|
* blob that trails the cursor with momentum.
|
||||||
|
*
|
||||||
|
* How the cursor-reactivity works:
|
||||||
|
* - We track the raw pointer in 0..1 UV space and LERP a smoothed pointer
|
||||||
|
* toward it every frame (momentum => the glow trails, never snaps).
|
||||||
|
* - We also track pointer *velocity* (smoothed delta) and feed it to the
|
||||||
|
* shader as `uVel`, so fast flicks visibly push/stretch the fluid.
|
||||||
|
* - In the shader, the metaballs and the domain-warp offset are pulled toward
|
||||||
|
* `uPointer`; a soft radial glow blooms at the pointer; warp strength scales
|
||||||
|
* with `uVel` so the surface "reacts" to how you move.
|
||||||
|
*
|
||||||
|
* Variants:
|
||||||
|
* - "hero": subtle, recedes behind the headline.
|
||||||
|
* - "cta": bigger, brighter, faster — the closing dramatic moment.
|
||||||
|
*
|
||||||
|
* Performance & resilience:
|
||||||
|
* - Pauses rendering when the tab is hidden OR the canvas is offscreen (IO).
|
||||||
|
* - DPR capped at 1.6 so it never tanks fill-rate / LCP.
|
||||||
|
* - WebGL capability check: if no GL context, we render nothing and the CSS
|
||||||
|
* gradient fallback (always painted underneath) shows through.
|
||||||
|
* - prefers-reduced-motion: we never start the GL loop; the static CSS
|
||||||
|
* gradient fallback stands in.
|
||||||
|
* - Full cleanup on unmount (RAF, listeners, GL context lost).
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
|
||||||
|
|
||||||
|
type Variant = "hero" | "cta";
|
||||||
|
|
||||||
|
const FRAG = /* glsl */ `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform float uTime;
|
||||||
|
uniform vec2 uRes;
|
||||||
|
uniform vec2 uPointer; // smoothed pointer, 0..1 (y up)
|
||||||
|
uniform float uVel; // smoothed pointer speed 0..~1
|
||||||
|
uniform float uIntensity; // variant intensity
|
||||||
|
uniform float uReveal; // 0..1 fade-in on mount
|
||||||
|
|
||||||
|
varying vec2 vUv;
|
||||||
|
|
||||||
|
// brand palette
|
||||||
|
const vec3 C_BLUE = vec3(0.231, 0.510, 0.965); // #3b82f6
|
||||||
|
const vec3 C_VIOLET = vec3(0.545, 0.361, 0.965); // #8b5cf6
|
||||||
|
const vec3 C_EMER = vec3(0.063, 0.725, 0.506); // #10b981
|
||||||
|
const vec3 C_BG = vec3(0.027, 0.027, 0.043); // #07070b
|
||||||
|
|
||||||
|
// hash / value noise
|
||||||
|
float hash(vec2 p){
|
||||||
|
p = fract(p * vec2(123.34, 456.21));
|
||||||
|
p += dot(p, p + 45.32);
|
||||||
|
return fract(p.x * p.y);
|
||||||
|
}
|
||||||
|
float noise(vec2 p){
|
||||||
|
vec2 i = floor(p);
|
||||||
|
vec2 f = fract(p);
|
||||||
|
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||||
|
float a = hash(i);
|
||||||
|
float b = hash(i + vec2(1.0, 0.0));
|
||||||
|
float c = hash(i + vec2(0.0, 1.0));
|
||||||
|
float d = hash(i + vec2(1.0, 1.0));
|
||||||
|
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||||
|
}
|
||||||
|
float fbm(vec2 p){
|
||||||
|
float v = 0.0;
|
||||||
|
float a = 0.5;
|
||||||
|
for (int i = 0; i < 5; i++){
|
||||||
|
v += a * noise(p);
|
||||||
|
p *= 2.0;
|
||||||
|
a *= 0.5;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// soft metaball field — sum of inverse-distance blobs that orbit + drift
|
||||||
|
float metaballs(vec2 p, vec2 ptr, float t, float speed){
|
||||||
|
float f = 0.0;
|
||||||
|
for (int i = 0; i < 5; i++){
|
||||||
|
float fi = float(i);
|
||||||
|
// each blob orbits on its own phase
|
||||||
|
vec2 c = vec2(
|
||||||
|
0.5 + 0.34 * sin(t * speed * (0.4 + fi * 0.12) + fi * 1.7),
|
||||||
|
0.5 + 0.30 * cos(t * speed * (0.5 + fi * 0.09) + fi * 2.3)
|
||||||
|
);
|
||||||
|
// pull every blob a little toward the pointer => the field "flows" to it
|
||||||
|
c = mix(c, ptr, 0.18 + 0.05 * fi);
|
||||||
|
float r = 0.16 + 0.05 * sin(t * 0.6 + fi);
|
||||||
|
f += r * r / (dot(p - c, p - c) + 0.0009);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main(){
|
||||||
|
vec2 uv = vUv;
|
||||||
|
// correct for aspect so blobs stay round-ish
|
||||||
|
float aspect = uRes.x / max(uRes.y, 1.0);
|
||||||
|
vec2 p = uv;
|
||||||
|
p.x *= aspect;
|
||||||
|
vec2 ptr = uPointer;
|
||||||
|
ptr.x *= aspect;
|
||||||
|
|
||||||
|
float t = uTime;
|
||||||
|
|
||||||
|
// domain warp — distortion grows toward the pointer and with pointer speed
|
||||||
|
float dPtr = distance(p, ptr);
|
||||||
|
float pull = exp(-dPtr * 2.4); // 1 near cursor -> 0 far
|
||||||
|
float warpAmt = (0.18 + uVel * 0.9) * (0.4 + pull);
|
||||||
|
vec2 q = p + warpAmt * vec2(
|
||||||
|
fbm(p * 2.2 + vec2(t * 0.10, t * 0.07)),
|
||||||
|
fbm(p * 2.2 + vec2(-t * 0.08, t * 0.12) + 5.2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// base flowing fbm field
|
||||||
|
float n = fbm(q * 1.7 + vec2(t * 0.05, -t * 0.04));
|
||||||
|
|
||||||
|
// metaballs flowing toward the pointer
|
||||||
|
float mb = metaballs(q, ptr, t, 0.7 + uVel * 1.2);
|
||||||
|
float field = smoothstep(0.7, 1.9, mb) * 0.9 + n * 0.6;
|
||||||
|
|
||||||
|
// iridescent color ramp across the field
|
||||||
|
vec3 col = mix(C_BLUE, C_VIOLET, smoothstep(0.15, 0.7, field + n * 0.3));
|
||||||
|
col = mix(col, C_EMER, smoothstep(0.55, 1.05, field + 0.18 * sin(t * 0.4 + uv.x * 3.0)));
|
||||||
|
|
||||||
|
// composite onto the dark canvas by the field strength
|
||||||
|
float lum = smoothstep(0.05, 1.2, field);
|
||||||
|
vec3 outc = mix(C_BG, col, clamp(lum, 0.0, 1.0) * uIntensity);
|
||||||
|
|
||||||
|
// pointer bloom — a bright additive glow that trails the cursor
|
||||||
|
float glow = exp(-dPtr * (4.5 - uVel * 1.5)) * (0.45 + uVel * 0.6);
|
||||||
|
vec3 glowCol = mix(C_VIOLET, C_EMER, 0.4 + 0.4 * sin(t * 0.7));
|
||||||
|
outc += glowCol * glow * uIntensity;
|
||||||
|
|
||||||
|
// subtle grain so it never bands on dark gradients
|
||||||
|
float g = hash(uv * uRes.xy * 0.5 + t) - 0.5;
|
||||||
|
outc += g * 0.018;
|
||||||
|
|
||||||
|
// soft vignette keeps edges grounded in the canvas
|
||||||
|
float vig = smoothstep(1.25, 0.25, length(uv - 0.5));
|
||||||
|
outc = mix(C_BG, outc, 0.35 + 0.65 * vig);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(outc * uReveal, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VERT = /* glsl */ `
|
||||||
|
attribute vec2 uv;
|
||||||
|
attribute vec2 position;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main(){
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = vec4(position, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function FluidBackground({
|
||||||
|
variant = "hero",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
variant?: Variant;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduce) return; // CSS gradient fallback stands in
|
||||||
|
|
||||||
|
// --- WebGL capability check ---
|
||||||
|
let renderer: Renderer;
|
||||||
|
try {
|
||||||
|
renderer = new Renderer({
|
||||||
|
alpha: false,
|
||||||
|
antialias: false,
|
||||||
|
dpr: Math.min(window.devicePixelRatio || 1, 1.6),
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return; // no GL -> CSS fallback shows
|
||||||
|
}
|
||||||
|
const gl = renderer.gl;
|
||||||
|
if (!gl) return;
|
||||||
|
|
||||||
|
const canvas = gl.canvas as HTMLCanvasElement;
|
||||||
|
canvas.className = "fluid__canvas";
|
||||||
|
canvas.setAttribute("aria-hidden", "true");
|
||||||
|
wrap.appendChild(canvas);
|
||||||
|
|
||||||
|
const isCta = variant === "cta";
|
||||||
|
// cta is bigger/faster but slightly toned so the centred headline stays
|
||||||
|
// legible against it (the readability backdrop does the rest)
|
||||||
|
const intensity = isCta ? 0.9 : 0.78;
|
||||||
|
|
||||||
|
const geometry = new Triangle(gl);
|
||||||
|
const program = new Program(gl, {
|
||||||
|
vertex: VERT,
|
||||||
|
fragment: FRAG,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uRes: { value: new Vec2(1, 1) },
|
||||||
|
uPointer: { value: new Vec2(0.5, 0.5) },
|
||||||
|
uVel: { value: 0 },
|
||||||
|
uIntensity: { value: intensity },
|
||||||
|
uReveal: { value: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mesh = new Mesh(gl, { geometry, program });
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const w = wrap.clientWidth || window.innerWidth;
|
||||||
|
const h = wrap.clientHeight || window.innerHeight;
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
program.uniforms.uRes.value.set(gl.drawingBufferWidth, gl.drawingBufferHeight);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(wrap);
|
||||||
|
|
||||||
|
// --- pointer tracking (raw -> smoothed, with velocity) ---
|
||||||
|
const rawPtr = { x: 0.5, y: 0.5 };
|
||||||
|
const smoothPtr = { x: 0.5, y: 0.5 };
|
||||||
|
let vel = 0;
|
||||||
|
let lastX = 0.5;
|
||||||
|
let lastY = 0.5;
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
rawPtr.x = (e.clientX - r.left) / r.width;
|
||||||
|
// flip Y so shader-space y is up and matches screen intuition
|
||||||
|
rawPtr.y = 1 - (e.clientY - r.top) / r.height;
|
||||||
|
};
|
||||||
|
// listen on window so movement is tracked even over the content layer
|
||||||
|
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||||
|
|
||||||
|
// --- visibility / offscreen pausing ---
|
||||||
|
let running = false;
|
||||||
|
let rafId = 0;
|
||||||
|
let inView = true;
|
||||||
|
let pageVisible = !document.hidden;
|
||||||
|
const startTime = performance.now();
|
||||||
|
let lastTick = startTime;
|
||||||
|
|
||||||
|
const frame = (now: number) => {
|
||||||
|
if (!running) return;
|
||||||
|
const dt = Math.min((now - lastTick) / 1000, 0.05);
|
||||||
|
lastTick = now;
|
||||||
|
|
||||||
|
// momentum: lerp smoothed pointer toward raw
|
||||||
|
const ease = 0.07;
|
||||||
|
smoothPtr.x += (rawPtr.x - smoothPtr.x) * ease;
|
||||||
|
smoothPtr.y += (rawPtr.y - smoothPtr.y) * ease;
|
||||||
|
|
||||||
|
// velocity from smoothed delta, decayed for a soft trailing reaction
|
||||||
|
const dx = smoothPtr.x - lastX;
|
||||||
|
const dy = smoothPtr.y - lastY;
|
||||||
|
lastX = smoothPtr.x;
|
||||||
|
lastY = smoothPtr.y;
|
||||||
|
const instVel = Math.min(Math.hypot(dx, dy) * 60, 1);
|
||||||
|
vel += (instVel - vel) * 0.12;
|
||||||
|
|
||||||
|
program.uniforms.uTime.value = (now - startTime) / 1000;
|
||||||
|
program.uniforms.uPointer.value.set(smoothPtr.x, smoothPtr.y);
|
||||||
|
program.uniforms.uVel.value = vel;
|
||||||
|
// ease the reveal in over ~1s
|
||||||
|
const rev = program.uniforms.uReveal.value as number;
|
||||||
|
if (rev < 1) program.uniforms.uReveal.value = Math.min(1, rev + dt * 1.4);
|
||||||
|
|
||||||
|
renderer.render({ scene: mesh });
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (running || !inView || !pageVisible) return;
|
||||||
|
running = true;
|
||||||
|
lastTick = performance.now();
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
running = false;
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
inView = entry.isIntersecting;
|
||||||
|
if (inView) start();
|
||||||
|
else stop();
|
||||||
|
},
|
||||||
|
{ threshold: 0 }
|
||||||
|
);
|
||||||
|
io.observe(wrap);
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
pageVisible = !document.hidden;
|
||||||
|
if (pageVisible) start();
|
||||||
|
else stop();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
io.disconnect();
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("pointermove", onPointerMove);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
const ext = gl.getExtension("WEBGL_lose_context");
|
||||||
|
ext?.loseContext();
|
||||||
|
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||||||
|
};
|
||||||
|
}, [variant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapRef}
|
||||||
|
className={`fluid fluid--${variant} ${className ?? ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* static gradient fallback — always painted underneath; the only thing
|
||||||
|
shown under reduced-motion / no-WebGL */}
|
||||||
|
<div className="fluid__fallback" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,13 +17,10 @@ import { useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { gsap, SplitText } from "./gsap";
|
import { gsap, SplitText } from "./gsap";
|
||||||
import Magnetic from "./Magnetic";
|
import Magnetic from "./Magnetic";
|
||||||
|
import FluidBackground from "./FluidBackground";
|
||||||
import { SITE } from "../content";
|
import { SITE } from "../content";
|
||||||
|
|
||||||
/* Custom GSAP eases that mirror the CSS tokens (registered once, idempotent). */
|
/* "emilOut" ease is registered once in ./gsap (shared across components). */
|
||||||
gsap.registerEase?.("emilOut", (p) => {
|
|
||||||
// approximates cubic-bezier(0.23,1,0.32,1) feel — strong, soft landing
|
|
||||||
return 1 - Math.pow(1 - p, 3.2);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const root = useRef<HTMLDivElement>(null);
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -193,7 +190,12 @@ export default function Hero() {
|
||||||
aria-labelledby="hero-h1"
|
aria-labelledby="hero-h1"
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
>
|
>
|
||||||
{/* ---- background video (deferred) + poster + scrims ---- */}
|
{/* ---- live WebGL fluid background (cursor-reactive) ---- */}
|
||||||
|
<FluidBackground variant="hero" />
|
||||||
|
|
||||||
|
{/* ---- background video (deferred) + poster + scrims ----
|
||||||
|
kept as a subtle moving TEXTURE layered over the fluid; the fluid is
|
||||||
|
now the dominant, cursor-reactive element. */}
|
||||||
<div className="hero__media" aria-hidden="true">
|
<div className="hero__media" aria-hidden="true">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,22 @@ import { useEffect, useRef } from "react";
|
||||||
import { gsap } from "./gsap";
|
import { gsap } from "./gsap";
|
||||||
import { processSteps } from "../content";
|
import { processSteps } from "../content";
|
||||||
|
|
||||||
// Four target paths for the morph (drawn on a 100x100 canvas).
|
/* Four MEANINGFUL, recognizable icon paths for the morph (100x100 canvas).
|
||||||
|
Drawn as filled silhouettes with similar complexity so MorphSVG tweens
|
||||||
|
cleanly between them — never to nonsense:
|
||||||
|
Audit -> magnifying glass
|
||||||
|
Plan -> target (concentric rings + center)
|
||||||
|
Execute -> rocket (rising)
|
||||||
|
Report -> line chart trending up with plotted nodes */
|
||||||
const SHAPES = [
|
const SHAPES = [
|
||||||
// Audit — magnifying glass
|
// Audit — magnifying glass (lens ring + handle)
|
||||||
"M44 20a24 24 0 1 0 15 43l18 18 7-7-18-18A24 24 0 0 0 44 20Zm0 10a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z",
|
"M46 16a26 26 0 1 0 16 46.6l16.7 16.7a5 5 0 0 0 7-7L69 71.6A26 26 0 0 0 46 16Zm0 12a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z",
|
||||||
// Plan — connected route / nodes
|
// Plan — target / bullseye (outer ring + mid ring + center dot)
|
||||||
"M22 30a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm40 40a8 8 0 1 1 16 0 8 8 0 0 1-16 0ZM30 38v8a16 16 0 0 0 16 16h8a16 16 0 0 1 16 16v-2 2h-8a26 26 0 0 1-26-26v-8Z",
|
"M50 14a36 36 0 1 0 0 72 36 36 0 0 0 0-72Zm0 12a24 24 0 1 1 0 48 24 24 0 0 1 0-48Zm0 12a12 12 0 1 0 0 24 12 12 0 0 0 0-24Z",
|
||||||
// Execute — lightning bolt
|
// Execute — rocket (nose, body, fins) rising
|
||||||
"M55 14 26 58h20l-6 30 32-46H50l9-28Z",
|
"M50 12c12 8 18 22 18 38l-6 14H38l-6-14c0-16 6-30 18-38Zm0 20a6 6 0 1 0 0 12 6 6 0 0 0 0-12ZM34 66l-8 16 14-6Zm32 0 8 16-14-6Z",
|
||||||
// Report — bar chart trending up
|
// Report — line chart trending up (axes + plotted line + nodes)
|
||||||
"M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14",
|
"M22 18v54a6 6 0 0 0 6 6h54v-10H30V18ZM40 60l12-14 10 8 18-22 7 6-23 28-10-8-9 11Z",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ACCENTS = ["#3b82f6", "#8b5cf6", "#10b981", "#7c3aed"];
|
const ACCENTS = ["#3b82f6", "#8b5cf6", "#10b981", "#7c3aed"];
|
||||||
|
|
@ -45,6 +51,7 @@ export default function ProcessLoop() {
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
|
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
|
||||||
|
const morphGlow = el.querySelector<SVGPathElement>(".loop-glyph__glow");
|
||||||
if (!morph) return;
|
if (!morph) return;
|
||||||
const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
|
const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
|
||||||
const panels = gsap.utils.toArray<HTMLElement>(".loop-panel");
|
const panels = gsap.utils.toArray<HTMLElement>(".loop-panel");
|
||||||
|
|
@ -84,10 +91,12 @@ export default function ProcessLoop() {
|
||||||
SHAPES.forEach((shape, i) => {
|
SHAPES.forEach((shape, i) => {
|
||||||
if (i === 0) return; // start shape already in markup
|
if (i === 0) return; // start shape already in markup
|
||||||
const at = i - 1;
|
const at = i - 1;
|
||||||
// morph the glyph + a counter-rotation so it tumbles as it transforms,
|
// morph the glyph (+ its glow clone in sync) + a gentle counter-rotation
|
||||||
// and grow the rail fill (+ a node that rides the fill edge).
|
// so it tumbles as it transforms, and grow the rail fill.
|
||||||
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at)
|
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at);
|
||||||
.to(".loop-glyph", { rotate: i * 5, duration: 1, ease: "power2.inOut" }, at)
|
if (morphGlow)
|
||||||
|
tl.to(morphGlow, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at);
|
||||||
|
tl.to(".loop-glyph", { rotate: i * 5, duration: 1, ease: "power2.inOut" }, at)
|
||||||
.to(
|
.to(
|
||||||
".loop-rail__fill",
|
".loop-rail__fill",
|
||||||
{ scaleY: i / (total - 1), duration: 1, ease: "none" },
|
{ scaleY: i / (total - 1), duration: 1, ease: "none" },
|
||||||
|
|
@ -137,17 +146,41 @@ export default function ProcessLoop() {
|
||||||
<div className="loop-card" aria-hidden="true">
|
<div className="loop-card" aria-hidden="true">
|
||||||
<div className="loop-card__glow" />
|
<div className="loop-card__glow" />
|
||||||
|
|
||||||
{/* morphing glyph */}
|
{/* morphing glyph — dimensional: a blurred glow clone sits behind
|
||||||
|
the crisp gradient-filled + gradient-stroked face */}
|
||||||
<div className="loop-glyph">
|
<div className="loop-glyph">
|
||||||
<svg viewBox="0 0 100 100">
|
<svg viewBox="0 0 100 100">
|
||||||
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0%" stopColor="#3b82f6" />
|
<stop offset="0%" stopColor="#60a5fa" />
|
||||||
<stop offset="50%" stopColor="#8b5cf6" />
|
<stop offset="50%" stopColor="#a78bfa" />
|
||||||
<stop offset="100%" stopColor="#10b981" />
|
<stop offset="100%" stopColor="#34d399" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="loopStroke" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#dbeafe" />
|
||||||
|
<stop offset="100%" stopColor="#a7f3d0" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="loopGlow" x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="4" />
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
{/* soft glow clone (kept in sync with the face via MorphSVG) */}
|
||||||
|
<path
|
||||||
|
className="loop-glyph__glow"
|
||||||
|
d={SHAPES[0]}
|
||||||
|
fill="url(#loopGrad)"
|
||||||
|
filter="url(#loopGlow)"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
{/* crisp face — gradient fill + light gradient stroke for dimension */}
|
||||||
|
<path
|
||||||
|
className="loop-glyph__path"
|
||||||
|
d={SHAPES[0]}
|
||||||
|
fill="url(#loopGrad)"
|
||||||
|
stroke="url(#loopStroke)"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,65 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* METRICS scoreboard.
|
* METRICS scoreboard.
|
||||||
* - CountUp numbers animate on view (Motion).
|
* - The four headline metrics count up on view (Motion) and are now the focus:
|
||||||
* - A second ANIMATED-SVG moment: a bar chart whose bars DRAW/grow + a baseline
|
* each cell is an interactive tile with a hover lift, an accent edge that
|
||||||
* that draws itself (GSAP DrawSVG) when the section scrolls in.
|
* wipes in, and a tiny per-metric sparkline that animates.
|
||||||
* Reduced-motion: numbers jump to final, bars render at full height (CSS).
|
* - The previously-meaningless decorative bars are replaced with a MEANINGFUL,
|
||||||
|
* LABELLED growth trend: a 6-month "client revenue" area+line chart with real
|
||||||
|
* month ticks, a value axis, and interactive data points that enlarge + show
|
||||||
|
* a tooltip on hover/focus. It communicates the compounding-growth story
|
||||||
|
* instead of being filler.
|
||||||
|
* Reduced-motion: numbers rest at final value, chart renders fully drawn, no
|
||||||
|
* count-up, no draw animation.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { gsap } from "./gsap";
|
import { gsap } from "./gsap";
|
||||||
import CountUp from "./CountUp";
|
import CountUp from "./CountUp";
|
||||||
import { metrics } from "../content";
|
import { metrics } from "../content";
|
||||||
|
|
||||||
const BARS = [38, 56, 72, 64, 88, 96]; // decorative growth bars (0..100)
|
/* 6-month sample "client revenue generated" trend (index, $M). Compounding
|
||||||
|
curve that mirrors the "$40M+ generated / 3.8x ROAS" headline story. */
|
||||||
|
const TREND = [
|
||||||
|
{ m: "Jan", v: 2.1 },
|
||||||
|
{ m: "Feb", v: 3.4 },
|
||||||
|
{ m: "Mar", v: 5.0 },
|
||||||
|
{ m: "Apr", v: 7.8 },
|
||||||
|
{ m: "May", v: 11.2 },
|
||||||
|
{ m: "Jun", v: 16.4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// tiny per-metric sparkline shapes (each different, matching the metric's story)
|
||||||
|
const SPARKS: string[] = [
|
||||||
|
"0,18 20,16 40,12 60,11 80,6 100,2", // revenue — steady climb
|
||||||
|
"0,16 20,14 40,15 60,9 80,7 100,3", // ROAS — climbing with a dip
|
||||||
|
"0,19 20,17 40,12 60,10 80,5 100,1", // organic — strong ramp
|
||||||
|
"0,8 20,7 40,8 60,6 80,7 100,5", // retention — high + stable
|
||||||
|
];
|
||||||
|
|
||||||
|
const W = 560;
|
||||||
|
const H = 200;
|
||||||
|
const PAD = { l: 38, r: 16, t: 18, b: 30 };
|
||||||
|
const maxV = Math.max(...TREND.map((d) => d.v));
|
||||||
|
|
||||||
|
function pt(i: number, v: number) {
|
||||||
|
const x = PAD.l + (i / (TREND.length - 1)) * (W - PAD.l - PAD.r);
|
||||||
|
const y = PAD.t + (1 - v / maxV) * (H - PAD.t - PAD.b);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
export default function Scoreboard() {
|
export default function Scoreboard() {
|
||||||
const root = useRef<HTMLElement>(null);
|
const root = useRef<HTMLElement>(null);
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const pts = TREND.map((d, i) => pt(i, d.v));
|
||||||
|
const linePath = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
|
||||||
|
const areaPath = `${linePath} L${pts[pts.length - 1].x},${H - PAD.b} L${pts[0].x},${H - PAD.b} Z`;
|
||||||
|
// y-axis gridlines at 0 / 50% / 100% of max
|
||||||
|
const yTicks = [0, 0.5, 1].map((f) => ({
|
||||||
|
f,
|
||||||
|
y: PAD.t + (1 - f) * (H - PAD.t - PAD.b),
|
||||||
|
label: `$${(maxV * f).toFixed(0)}M`,
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = root.current;
|
const el = root.current;
|
||||||
|
|
@ -27,36 +72,29 @@ export default function Scoreboard() {
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
scrollTrigger: { trigger: ".score__chart", start: "top 82%", once: true },
|
scrollTrigger: { trigger: ".score__chart", start: "top 82%", once: true },
|
||||||
});
|
});
|
||||||
// Baseline draws first, then bars grow up off it with a tight stagger and
|
// gridlines fade, the area wipes up, the line draws itself, points pop in,
|
||||||
// a soft landing (back ease => a hair of overshoot so they feel physical).
|
// and each metric sparkline draws — a coherent "data resolving" moment.
|
||||||
tl.from(".score__baseline", {
|
tl.from(".score__grid line", { autoAlpha: 0, duration: 0.5, stagger: 0.08 })
|
||||||
drawSVG: "0%",
|
.from(".score__area", { autoAlpha: 0, yPercent: 8, duration: 0.8, ease: "power2.out" }, "-=0.2")
|
||||||
duration: 0.8,
|
.from(".score__line", { drawSVG: "0%", duration: 1.4, ease: "power2.inOut" }, "-=0.7")
|
||||||
ease: "power2.out",
|
|
||||||
})
|
|
||||||
.from(
|
.from(
|
||||||
".score-bar",
|
".score__pt",
|
||||||
{
|
{ scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2.2)", stagger: 0.08 },
|
||||||
scaleY: 0,
|
"-=0.6"
|
||||||
transformOrigin: "bottom",
|
|
||||||
duration: 1,
|
|
||||||
ease: "back.out(1.4)",
|
|
||||||
stagger: 0.07,
|
|
||||||
},
|
|
||||||
"-=0.45"
|
|
||||||
)
|
)
|
||||||
.from(
|
.from(
|
||||||
".score-bar__cap",
|
".score__mlabel",
|
||||||
{
|
{ autoAlpha: 0, y: 6, duration: 0.4, stagger: 0.06 },
|
||||||
scale: 0,
|
"-=0.8"
|
||||||
autoAlpha: 0,
|
|
||||||
transformOrigin: "center",
|
|
||||||
duration: 0.5,
|
|
||||||
ease: "back.out(2.4)",
|
|
||||||
stagger: 0.07,
|
|
||||||
},
|
|
||||||
"-=0.9"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
gsap.from(".score__spark polyline", {
|
||||||
|
drawSVG: "0%",
|
||||||
|
duration: 1,
|
||||||
|
ease: "power2.out",
|
||||||
|
stagger: 0.12,
|
||||||
|
scrollTrigger: { trigger: ".score__grid-stats", start: "top 85%", once: true },
|
||||||
|
});
|
||||||
}, el);
|
}, el);
|
||||||
|
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
|
|
@ -75,8 +113,9 @@ export default function Scoreboard() {
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<dl className="score__grid">
|
{/* four headline metrics — the focus; interactive tiles with sparklines */}
|
||||||
{metrics.map((m) => (
|
<dl className="score__grid score__grid-stats">
|
||||||
|
{metrics.map((m, i) => (
|
||||||
<div className="score__cell" key={m.label}>
|
<div className="score__cell" key={m.label}>
|
||||||
<dt className="sr-only">{m.label}</dt>
|
<dt className="sr-only">{m.label}</dt>
|
||||||
<dd
|
<dd
|
||||||
|
|
@ -92,26 +131,126 @@ export default function Scoreboard() {
|
||||||
<p className="score__lab" aria-hidden="true">
|
<p className="score__lab" aria-hidden="true">
|
||||||
{m.label}
|
{m.label}
|
||||||
</p>
|
</p>
|
||||||
|
<svg
|
||||||
|
className="score__spark"
|
||||||
|
viewBox="0 0 100 20"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={SPARKS[i]}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{/* decorative animated bar chart */}
|
{/* meaningful labelled growth trend — replaces the decorative bars */}
|
||||||
<div className="score__chart" aria-hidden="true">
|
<figure className="score__chart">
|
||||||
<div className="score__bars">
|
<figcaption className="score__chart-cap">
|
||||||
{BARS.map((h, i) => (
|
Client revenue generated — cumulative, sample 6-month engagement
|
||||||
<span key={i} className="score-bar" style={{ height: `${h}%` }}>
|
</figcaption>
|
||||||
<span className="score-bar__cap" />
|
<svg
|
||||||
</span>
|
className="score__trend"
|
||||||
))}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
</div>
|
role="img"
|
||||||
<svg className="score__axis" viewBox="0 0 600 8" preserveAspectRatio="none">
|
aria-label="Cumulative client revenue rising from $2.1M in January to $16.4M in June (sample data)."
|
||||||
<line className="score__baseline" x1="0" y1="4" x2="600" y2="4" />
|
>
|
||||||
</svg>
|
<defs>
|
||||||
</div>
|
<linearGradient id="scoreLine" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#2563eb" />
|
||||||
|
<stop offset="55%" stopColor="#7c3aed" />
|
||||||
|
<stop offset="100%" stopColor="#059669" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="scoreArea" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.28" />
|
||||||
|
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* y gridlines + value labels */}
|
||||||
|
<g className="score__grid">
|
||||||
|
{yTicks.map((t) => (
|
||||||
|
<line key={t.f} x1={PAD.l} y1={t.y} x2={W - PAD.r} y2={t.y} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<g className="score__ylabels" aria-hidden="true">
|
||||||
|
{yTicks.map((t) => (
|
||||||
|
<text key={t.f} x={PAD.l - 8} y={t.y + 4} textAnchor="end">
|
||||||
|
{t.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path className="score__area" d={areaPath} fill="url(#scoreArea)" />
|
||||||
|
<path
|
||||||
|
className="score__line"
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#scoreLine)"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* interactive data points + month labels */}
|
||||||
|
{TREND.map((d, i) => {
|
||||||
|
const p = pts[i];
|
||||||
|
const on = hover === i;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={d.m}
|
||||||
|
className="score__ptg"
|
||||||
|
onMouseEnter={() => setHover(i)}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
onFocus={() => setHover(i)}
|
||||||
|
onBlur={() => setHover(null)}
|
||||||
|
tabIndex={0}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${d.m}: $${d.v}M`}
|
||||||
|
>
|
||||||
|
{/* invisible larger hit area */}
|
||||||
|
<rect
|
||||||
|
x={p.x - 22}
|
||||||
|
y={PAD.t}
|
||||||
|
width={44}
|
||||||
|
height={H - PAD.t - PAD.b}
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="score__pt"
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={on ? 7 : 4.5}
|
||||||
|
/>
|
||||||
|
<text className="score__mlabel" x={p.x} y={H - 10} textAnchor="middle">
|
||||||
|
{d.m}
|
||||||
|
</text>
|
||||||
|
{on && (
|
||||||
|
<g className="score__tip">
|
||||||
|
<rect
|
||||||
|
x={p.x - 26}
|
||||||
|
y={p.y - 34}
|
||||||
|
width={52}
|
||||||
|
height={22}
|
||||||
|
rx={6}
|
||||||
|
/>
|
||||||
|
<text x={p.x} y={p.y - 19} textAnchor="middle">
|
||||||
|
${d.v}M
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</figure>
|
||||||
|
|
||||||
{/* anchors the section + sets honest expectation; kills the empty gap
|
|
||||||
above the gradient divider */}
|
|
||||||
<p className="score__foot">Sample data — your numbers, reported monthly.</p>
|
<p className="score__foot">Sample data — your numbers, reported monthly.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
260
app/components/Testimonials.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TESTIMONIALS — an interactive, auto-advancing carousel that feels substantial.
|
||||||
|
* - 6 sample testimonials, one large featured quote at a time, with a
|
||||||
|
* monogram avatar, name, role, company and a result chip.
|
||||||
|
* - Auto-advances every ~6s; pauses on hover/focus-within and when offscreen
|
||||||
|
* or the tab is hidden. Manual prev/next buttons + clickable dots.
|
||||||
|
* - A thumbnail rail of all clients (monogram avatars) doubles as navigation;
|
||||||
|
* the active one is highlighted.
|
||||||
|
* - Slides crossfade/slide with Motion (AnimatePresence) using the page's
|
||||||
|
* EASE curves; direction-aware.
|
||||||
|
* - Fully keyboard accessible: real <button>s, arrow-key support, aria-live
|
||||||
|
* announces the active slide, aria-roledescription="carousel".
|
||||||
|
* - prefers-reduced-motion: no auto-advance, instant slide swaps.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import Reveal from "./Reveal";
|
||||||
|
import { testimonials } from "../content";
|
||||||
|
import { EASE_OUT } from "./motion";
|
||||||
|
|
||||||
|
const AUTOPLAY_MS = 6000;
|
||||||
|
|
||||||
|
export default function Testimonials() {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const [[index, dir], setState] = useState<[number, number]>([0, 0]);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLElement>(null);
|
||||||
|
const inView = useRef(true);
|
||||||
|
|
||||||
|
const count = testimonials.length;
|
||||||
|
const go = useCallback(
|
||||||
|
(next: number, direction: number) => {
|
||||||
|
setState([(next + count) % count, direction]);
|
||||||
|
},
|
||||||
|
[count]
|
||||||
|
);
|
||||||
|
const next = useCallback(() => go(index + 1, 1), [go, index]);
|
||||||
|
const prev = useCallback(() => go(index - 1, -1), [go, index]);
|
||||||
|
|
||||||
|
// autoplay — paused on hover/focus, offscreen, hidden tab, reduced-motion
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduce) return;
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([e]) => {
|
||||||
|
inView.current = e.isIntersecting;
|
||||||
|
},
|
||||||
|
{ threshold: 0.25 }
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (paused || !inView.current || document.hidden) return;
|
||||||
|
setState(([i]) => [(i + 1) % count, 1]);
|
||||||
|
}, AUTOPLAY_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
io.disconnect();
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [reduce, paused, count]);
|
||||||
|
|
||||||
|
// keyboard arrows when the carousel has focus
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
next();
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
prev();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = testimonials[index];
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
enter: (d: number) =>
|
||||||
|
reduce
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, x: d > 0 ? 60 : -60, filter: "blur(6px)" },
|
||||||
|
center: { opacity: 1, x: 0, filter: "blur(0px)" },
|
||||||
|
exit: (d: number) =>
|
||||||
|
reduce
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, x: d > 0 ? -60 : 60, filter: "blur(6px)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={rootRef}
|
||||||
|
className="quotes frame"
|
||||||
|
aria-labelledby="quotes-h"
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
onFocusCapture={() => setPaused(true)}
|
||||||
|
onBlurCapture={() => setPaused(false)}
|
||||||
|
>
|
||||||
|
<div className="wrap">
|
||||||
|
<header className="sec-head">
|
||||||
|
<p className="kicker">
|
||||||
|
<span className="kicker__dot" />
|
||||||
|
In their words — sample
|
||||||
|
</p>
|
||||||
|
<Reveal as="h2" variant="clip">
|
||||||
|
<span id="quotes-h" className="display sec-head__title">
|
||||||
|
The number is the point
|
||||||
|
</span>
|
||||||
|
</Reveal>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="carousel"
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
aria-label="Client testimonials"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<div className="carousel__stage">
|
||||||
|
{/* decorative oversized quote mark */}
|
||||||
|
<span className="carousel__mark display" aria-hidden="true">
|
||||||
|
“
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait" custom={dir} initial={false}>
|
||||||
|
<motion.figure
|
||||||
|
key={index}
|
||||||
|
className="carousel__slide"
|
||||||
|
custom={dir}
|
||||||
|
variants={variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: reduce ? 0.2 : 0.55, ease: EASE_OUT }}
|
||||||
|
aria-roledescription="slide"
|
||||||
|
aria-label={`${index + 1} of ${count}`}
|
||||||
|
>
|
||||||
|
<blockquote className="carousel__quote display">
|
||||||
|
{t.quote}
|
||||||
|
</blockquote>
|
||||||
|
<figcaption className="carousel__by">
|
||||||
|
<span className="carousel__avatar">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={t.avatar}
|
||||||
|
alt=""
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
loading="lazy"
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="carousel__id">
|
||||||
|
<span className="carousel__name">{t.name}</span>
|
||||||
|
<span className="carousel__role">
|
||||||
|
{t.role}, {t.company}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="carousel__chip">{t.metric}</span>
|
||||||
|
</figcaption>
|
||||||
|
</motion.figure>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* controls */}
|
||||||
|
<div className="carousel__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="carousel__arrow"
|
||||||
|
onClick={prev}
|
||||||
|
aria-label="Previous testimonial"
|
||||||
|
data-cursor="Prev"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M15 5l-7 7 7 7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="carousel__dots" role="tablist" aria-label="Choose testimonial">
|
||||||
|
{testimonials.map((tt, i) => (
|
||||||
|
<li key={tt.name} role="presentation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={i === index}
|
||||||
|
aria-label={`${tt.name}, ${tt.company}`}
|
||||||
|
className={`carousel__dot ${i === index ? "is-active" : ""}`}
|
||||||
|
onClick={() => go(i, i > index ? 1 : -1)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="carousel__arrow"
|
||||||
|
onClick={next}
|
||||||
|
aria-label="Next testimonial"
|
||||||
|
data-cursor="Next"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* avatar rail — all clients; doubles as quick nav */}
|
||||||
|
<ul className="carousel__rail" aria-hidden="true">
|
||||||
|
{testimonials.map((tt, i) => (
|
||||||
|
<li key={tt.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`carousel__railitem ${i === index ? "is-active" : ""}`}
|
||||||
|
onClick={() => go(i, i > index ? 1 : -1)}
|
||||||
|
>
|
||||||
|
<span className="carousel__railmono">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={tt.avatar}
|
||||||
|
alt=""
|
||||||
|
width={44}
|
||||||
|
height={44}
|
||||||
|
loading="lazy"
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="carousel__railname">{tt.company}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* live region for screen readers */}
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
Testimonial {index + 1} of {count}: {t.name}, {t.role} at {t.company}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,9 @@ import { SplitText } from "gsap/SplitText";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText);
|
gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText);
|
||||||
|
// Shared signature ease (mirrors the CSS --ease-out token). Registered once
|
||||||
|
// here so every component can use ease:"emilOut" without re-registering.
|
||||||
|
gsap.registerEase("emilOut", (p) => 1 - Math.pow(1 - p, 3.2));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { gsap, ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText };
|
export { gsap, ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText };
|
||||||
|
|
|
||||||
|
|
@ -57,38 +57,56 @@ export const metrics = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Case studies. Visuals are now CODED data charts (unique `bars` per case +
|
* Case studies. Each card now pairs a distinct SECTOR PHOTO with a data
|
||||||
* `accent`), not the rejected repetitive raster images — so no two look alike.
|
* visualisation tailored to its story, so no two cards look alike:
|
||||||
|
* - Fashion: dual-metric viz — CPA falling while ROAS climbs.
|
||||||
|
* - SaaS: a ramping demo-requests line (slow start, steep finish).
|
||||||
|
* - Clinic: monthly bookings bars converging on a target line.
|
||||||
|
* `viz` selects the chart; `series`/`series2` hold the (sample) data 0..100.
|
||||||
*/
|
*/
|
||||||
export const cases = [
|
export const cases = [
|
||||||
{
|
{
|
||||||
tag: "E-commerce · Fashion",
|
tag: "E-commerce · Fashion",
|
||||||
|
image: "/assets/case-fashion.webp",
|
||||||
|
alt: "A minimalist clothing rack of folded knitwear in a dim showroom.",
|
||||||
problem: "Rising ad costs were eating the margin.",
|
problem: "Rising ad costs were eating the margin.",
|
||||||
result: "−34% CPA and +52% ROAS in 90 days",
|
result: "−34% CPA and +52% ROAS in 90 days",
|
||||||
how: "Meta + Google Shopping restructure.",
|
how: "Meta + Google Shopping restructure.",
|
||||||
metricNum: "+52%",
|
metricNum: "+52%",
|
||||||
metricLabel: "ROAS in 90 days",
|
metricLabel: "ROAS in 90 days",
|
||||||
bars: [28, 40, 36, 58, 72, 92],
|
viz: "dual",
|
||||||
|
// CPA falling (down is good) + ROAS rising — the two-line story
|
||||||
|
series: [92, 80, 74, 60, 52, 42], // CPA index, falling
|
||||||
|
series2: [38, 44, 50, 62, 78, 96], // ROAS index, rising
|
||||||
accent: "#8b5cf6",
|
accent: "#8b5cf6",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "B2B SaaS",
|
tag: "B2B SaaS",
|
||||||
|
image: "/assets/case-saas.webp",
|
||||||
|
alt: "A laptop on a desk at dusk showing a glowing analytics dashboard.",
|
||||||
problem: "Plenty of traffic, no pipeline.",
|
problem: "Plenty of traffic, no pipeline.",
|
||||||
result: "+217% qualified demo requests in 6 months",
|
result: "+217% qualified demo requests in 6 months",
|
||||||
how: "SEO + content + LinkedIn.",
|
how: "SEO + content + LinkedIn.",
|
||||||
metricNum: "+217%",
|
metricNum: "+217%",
|
||||||
metricLabel: "demo requests",
|
metricLabel: "demo requests",
|
||||||
bars: [18, 22, 30, 48, 70, 96],
|
viz: "ramp",
|
||||||
|
// a slow-then-steep demo-requests ramp
|
||||||
|
series: [12, 16, 24, 38, 64, 96],
|
||||||
accent: "#3b82f6",
|
accent: "#3b82f6",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "Aesthetic clinic",
|
tag: "Aesthetic clinic",
|
||||||
|
image: "/assets/case-clinic.webp",
|
||||||
|
alt: "A calm, softly-lit aesthetic clinic treatment room with a garden view.",
|
||||||
problem: "Empty calendar despite the ad spend.",
|
problem: "Empty calendar despite the ad spend.",
|
||||||
result: "+128 booked consultations a month",
|
result: "+128 booked consultations a month",
|
||||||
how: "Paid + landing-page rebuild.",
|
how: "Paid + landing-page rebuild.",
|
||||||
metricNum: "+128",
|
metricNum: "+128",
|
||||||
metricLabel: "consultations / month",
|
metricLabel: "consultations / month",
|
||||||
bars: [24, 34, 30, 52, 66, 88],
|
viz: "bookings",
|
||||||
|
// monthly bookings bars climbing toward a capacity target line
|
||||||
|
series: [26, 38, 44, 58, 74, 92],
|
||||||
|
target: 85,
|
||||||
accent: "#10b981",
|
accent: "#10b981",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -100,16 +118,66 @@ export const processSteps = [
|
||||||
{ n: "04", name: "Report", desc: "Every month: marketing tied straight to pipeline and sales." },
|
{ n: "04", name: "Report", desc: "Every month: marketing tied straight to pipeline and sales." },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testimonials — illustrative SAMPLES (consistent with the footer disclaimer).
|
||||||
|
* Structured with name / role / company so the carousel can render coded
|
||||||
|
* monogram avatars and proper figcaptions. Swap for real, attributed quotes
|
||||||
|
* before launch.
|
||||||
|
*/
|
||||||
export const testimonials = [
|
export const testimonials = [
|
||||||
{
|
{
|
||||||
quote:
|
quote:
|
||||||
"We were spending $20k a month on ads with nothing to show. Six months later, marketing is our most predictable growth channel.",
|
"We were spending $20k a month on ads with nothing to show. Six months later, marketing is our most predictable growth channel.",
|
||||||
by: "Sarah Lin, Head of Growth, Lumen Apparel",
|
name: "Sarah Lin",
|
||||||
|
role: "Head of Growth",
|
||||||
|
company: "Lumen Apparel",
|
||||||
|
metric: "+52% ROAS",
|
||||||
|
avatar: "/assets/person-1.webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
quote:
|
quote:
|
||||||
"They talk in revenue, not impressions. First agency that moved our pipeline.",
|
"They talk in revenue, not impressions. First agency that actually moved our pipeline instead of our vanity dashboards.",
|
||||||
by: "Marcus Reyes, CMO, Northpeak SaaS",
|
name: "Marcus Reyes",
|
||||||
|
role: "CMO",
|
||||||
|
company: "Northpeak SaaS",
|
||||||
|
metric: "+217% demos",
|
||||||
|
avatar: "/assets/person-2.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"Our calendar went from empty to fully booked. The monthly report ties every dollar of spend straight to consultations.",
|
||||||
|
name: "Dr. Amara Osei",
|
||||||
|
role: "Founder",
|
||||||
|
company: "Vista Aesthetics",
|
||||||
|
metric: "+128 booked/mo",
|
||||||
|
avatar: "/assets/person-3.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"Finally an agency that pushes back when something isn't working. They killed two channels and doubled down on what sold.",
|
||||||
|
name: "Daniel Brenner",
|
||||||
|
role: "VP Marketing",
|
||||||
|
company: "Forge Commerce",
|
||||||
|
metric: "−34% CPA",
|
||||||
|
avatar: "/assets/person-4.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"Organic traffic used to be a black box. Now we can see exactly which content earns pipeline, and we brief around it.",
|
||||||
|
name: "Arjun Nair",
|
||||||
|
role: "Demand Gen Lead",
|
||||||
|
company: "Cobalt Systems",
|
||||||
|
metric: "+183% organic",
|
||||||
|
avatar: "/assets/person-5.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quote:
|
||||||
|
"The reporting alone is worth it. Our board finally trusts the marketing number because it reconciles with sales.",
|
||||||
|
name: "Elena Rossi",
|
||||||
|
role: "CEO",
|
||||||
|
company: "Maris Group",
|
||||||
|
metric: "92% retention",
|
||||||
|
avatar: "/assets/person-6.webp",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
584
app/globals.css
|
|
@ -232,6 +232,12 @@ body::before {
|
||||||
font-size: var(--step-4);
|
font-size: var(--step-4);
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
max-width: 16ch;
|
max-width: 16ch;
|
||||||
|
/* descender room so the clip-path reveal never crops g/y/p/q tails
|
||||||
|
(e.g. "grow your business"); compensated by a negative margin so the
|
||||||
|
rhythm below the title is unchanged */
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 0.16em;
|
||||||
|
margin-bottom: -0.16em;
|
||||||
}
|
}
|
||||||
.sec-head--center .sec-head__title { margin-inline: auto; }
|
.sec-head--center .sec-head__title { margin-inline: auto; }
|
||||||
|
|
||||||
|
|
@ -523,6 +529,45 @@ body::before {
|
||||||
.mobile-menu .btn { margin-top: 1rem; }
|
.mobile-menu .btn { margin-top: 1rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
7b. FLUID WEBGL BACKGROUND (cursor-reactive)
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
.fluid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none; /* never steals clicks from the content above */
|
||||||
|
}
|
||||||
|
.fluid__canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
display: block;
|
||||||
|
/* fade in over the fallback so there's no flash before the GL reveal ramp */
|
||||||
|
animation: fluidFade 0.9s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
@keyframes fluidFade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
/* static gradient stand-in: shown under reduced-motion / no-WebGL, and sits
|
||||||
|
beneath the canvas otherwise so first paint is never an empty black box */
|
||||||
|
.fluid__fallback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 60% at 25% 25%, rgba(59, 130, 246, 0.28), transparent 70%),
|
||||||
|
radial-gradient(55% 55% at 80% 30%, rgba(139, 92, 246, 0.26), transparent 70%),
|
||||||
|
radial-gradient(60% 60% at 55% 90%, rgba(16, 185, 129, 0.2), transparent 70%),
|
||||||
|
var(--c-bg);
|
||||||
|
}
|
||||||
|
.fluid--cta .fluid__fallback {
|
||||||
|
background:
|
||||||
|
radial-gradient(65% 65% at 20% 20%, rgba(59, 130, 246, 0.4), transparent 70%),
|
||||||
|
radial-gradient(60% 60% at 85% 25%, rgba(139, 92, 246, 0.4), transparent 70%),
|
||||||
|
radial-gradient(70% 70% at 50% 95%, rgba(16, 185, 129, 0.3), transparent 70%),
|
||||||
|
var(--c-bg);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
8. HERO
|
8. HERO
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
|
|
@ -537,14 +582,26 @@ body::before {
|
||||||
--mx: 50%;
|
--mx: 50%;
|
||||||
--my: 40%;
|
--my: 40%;
|
||||||
}
|
}
|
||||||
.hero__media { position: absolute; inset: -12% 0; z-index: 0; will-change: transform; }
|
/* fluid sits at z-index:0; the video media now layers OVER it as a soft texture
|
||||||
.hero__video { width: 100%; height: 100%; object-fit: cover; opacity: 0.55; }
|
(z-index:1, screen-blended at low opacity) so the cursor-reactive fluid is the
|
||||||
|
dominant background while the footage keeps a filmic grain on top */
|
||||||
|
.hero__media { position: absolute; inset: -12% 0; z-index: 1; will-change: transform; }
|
||||||
|
.hero__video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0.22;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
/* readability scrim — kept lighter than before so the live fluid stays visible
|
||||||
|
behind the copy, while a left-weighted gradient still guarantees H1 contrast.
|
||||||
|
Sits on the media layer (z-index:1) above the fluid. */
|
||||||
.hero__scrim {
|
.hero__scrim {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(7, 7, 11, 0.5), rgba(7, 7, 11, 0.82) 60%, var(--c-bg)),
|
linear-gradient(180deg, rgba(7, 7, 11, 0.35), rgba(7, 7, 11, 0.55) 65%, rgba(7, 7, 11, 0.8)),
|
||||||
linear-gradient(90deg, rgba(7, 7, 11, 0.72), transparent 62%);
|
linear-gradient(90deg, rgba(7, 7, 11, 0.78), rgba(7, 7, 11, 0.32) 48%, transparent 70%);
|
||||||
}
|
}
|
||||||
.hero__spotlight {
|
.hero__spotlight {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -625,9 +682,18 @@ body::before {
|
||||||
max-width: 15ch;
|
max-width: 15ch;
|
||||||
visibility: hidden; /* revealed by GSAP; reduced-motion fallback re-shows */
|
visibility: hidden; /* revealed by GSAP; reduced-motion fallback re-shows */
|
||||||
}
|
}
|
||||||
/* SplitText line wrappers — clip so words rise from behind a mask */
|
/* SplitText line wrappers — clip so words rise from behind a mask.
|
||||||
.hero__line { overflow: hidden; padding-bottom: 0.06em; }
|
The mask MUST leave room for descenders (g, y, p, q) or "grow"/"your" get
|
||||||
.hero__h1 .word { display: inline-block; }
|
their tails clipped. We pad the bottom of each masked line by .2em and pull
|
||||||
|
the next line up by the same amount with a negative margin so the visual
|
||||||
|
line spacing is unchanged (no layout shift). The words themselves also get
|
||||||
|
the same bottom padding so the mask never crops the glyph. */
|
||||||
|
.hero__line {
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 0.2em;
|
||||||
|
margin-bottom: -0.2em;
|
||||||
|
}
|
||||||
|
.hero__h1 .word { display: inline-block; padding-bottom: 0.2em; }
|
||||||
/* FOUC guard: hide stagger items until GSAP takes over (re-shown by JS, or by
|
/* FOUC guard: hide stagger items until GSAP takes over (re-shown by JS, or by
|
||||||
the reduced-motion fallback below). */
|
the reduced-motion fallback below). */
|
||||||
.hero__stagger { opacity: 0; }
|
.hero__stagger { opacity: 0; }
|
||||||
|
|
@ -968,16 +1034,46 @@ body::before {
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
13. SCOREBOARD (metrics)
|
13. SCOREBOARD (metrics)
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
/* trim the bottom padding ~40% so there's no large empty gap before the
|
|
||||||
gradient divider; the sub-label anchors the section */
|
|
||||||
.score.frame { padding-bottom: clamp(2.7rem, 5.4vw, 5.4rem); }
|
.score.frame { padding-bottom: clamp(2.7rem, 5.4vw, 5.4rem); }
|
||||||
.score__grid {
|
.score__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: clamp(1rem, 3vw, 2.5rem);
|
gap: clamp(0.8rem, 2vw, 1.5rem);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.score__num { font-size: var(--step-4); line-height: 1; font-variant-numeric: tabular-nums; }
|
/* interactive metric tiles — the focus of the section */
|
||||||
|
.score__cell {
|
||||||
|
position: relative;
|
||||||
|
padding: clamp(1.2rem, 2.5vw, 1.8rem) clamp(0.8rem, 1.5vw, 1.2rem);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--paper-line);
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
transition:
|
||||||
|
transform var(--t-mid) var(--ease-out),
|
||||||
|
box-shadow var(--t-slow) var(--ease-out),
|
||||||
|
border-color var(--t-mid) var(--ease-out);
|
||||||
|
}
|
||||||
|
.score__cell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; top: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 14px 14px 0 0;
|
||||||
|
background: var(--grad-brand);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform var(--t-mid) var(--ease-out);
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.score__cell:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: var(--paper-ink);
|
||||||
|
box-shadow: 0 24px 50px -28px rgba(20, 20, 26, 0.4);
|
||||||
|
}
|
||||||
|
.score__cell:hover::after { transform: scaleX(1); }
|
||||||
|
.score__cell:hover .score__spark { color: var(--violet-600); opacity: 1; }
|
||||||
|
}
|
||||||
|
.score__num { font-size: var(--step-3); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
.score__num.is-accent {
|
.score__num.is-accent {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
background: var(--grad-text-ink); /* dark gradient => legible on light paper */
|
background: var(--grad-text-ink); /* dark gradient => legible on light paper */
|
||||||
|
|
@ -985,13 +1081,55 @@ body::before {
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
.score__lab {
|
.score__lab {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.6rem;
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
color: var(--paper-dim);
|
color: var(--paper-dim);
|
||||||
max-width: 18ch;
|
max-width: 18ch;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
.score__chart { margin-top: clamp(2.5rem, 5vw, 4rem); max-width: 600px; margin-inline: auto; }
|
.score__spark {
|
||||||
|
width: 70%;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0.7rem auto 0;
|
||||||
|
color: #8a86b0;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: color var(--t-mid) var(--ease-out), opacity var(--t-mid) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* meaningful labelled growth trend chart */
|
||||||
|
.score__chart { margin-top: clamp(2.5rem, 5vw, 4rem); max-width: 640px; margin-inline: auto; }
|
||||||
|
.score__chart-cap {
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--paper-dim);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.score__trend { width: 100%; height: auto; }
|
||||||
|
.score__grid line { stroke: var(--paper-line); stroke-width: 1; }
|
||||||
|
.score__ylabels text,
|
||||||
|
.score__mlabel {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
fill: #6b675f; /* ~4.9:1 on paper */
|
||||||
|
}
|
||||||
|
.score__pt {
|
||||||
|
fill: var(--violet-600);
|
||||||
|
stroke: var(--paper);
|
||||||
|
stroke-width: 2;
|
||||||
|
transition: r 0.18s var(--ease-out);
|
||||||
|
}
|
||||||
|
.score__ptg { cursor: pointer; outline: none; }
|
||||||
|
.score__ptg:focus-visible .score__pt { r: 7; }
|
||||||
|
.score__ptg:focus-visible { outline: none; }
|
||||||
|
.score__tip rect { fill: var(--paper-ink); }
|
||||||
|
.score__tip text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
fill: var(--paper);
|
||||||
|
}
|
||||||
.score__foot {
|
.score__foot {
|
||||||
margin-top: clamp(1.4rem, 3vw, 2.2rem);
|
margin-top: clamp(1.4rem, 3vw, 2.2rem);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -1000,27 +1138,8 @@ body::before {
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: #6b675f; /* ~4.9:1 on paper */
|
color: #6b675f; /* ~4.9:1 on paper */
|
||||||
}
|
}
|
||||||
.score__bars { display: flex; align-items: flex-end; gap: clamp(0.6rem, 2vw, 1.4rem); height: 130px; }
|
|
||||||
.score-bar {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
background: var(--grad-brand);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.score-bar__cap {
|
|
||||||
position: absolute;
|
|
||||||
top: -5px; left: 50%;
|
|
||||||
width: 8px; height: 8px;
|
|
||||||
margin-left: -4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--emerald);
|
|
||||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.7);
|
|
||||||
}
|
|
||||||
.score__axis { width: 100%; height: 8px; margin-top: 4px; }
|
|
||||||
.score__baseline { stroke: var(--paper-ink); stroke-width: 2; }
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.score__grid { grid-template-columns: repeat(2, 1fr); gap: 2rem 1rem; }
|
.score__grid { grid-template-columns: repeat(2, 1fr); gap: 0.8rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
|
|
@ -1038,7 +1157,8 @@ body::before {
|
||||||
.case__inner {
|
.case__inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: clamp(1.4rem, 2.5vw, 2rem);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
|
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
|
||||||
border: 1px solid var(--c-line);
|
border: 1px solid var(--c-line);
|
||||||
|
|
@ -1055,42 +1175,89 @@ body::before {
|
||||||
transform: translateZ(40px);
|
transform: translateZ(40px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.case__glare { position: absolute; inset: 0; pointer-events: none; }
|
|
||||||
.case__head {
|
/* sector photo — leads the card with reserved aspect ratio (no CLS) */
|
||||||
display: flex;
|
.case__photo {
|
||||||
align-items: center;
|
position: relative;
|
||||||
justify-content: space-between;
|
aspect-ratio: 16 / 11;
|
||||||
gap: 1rem;
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--c-line);
|
||||||
|
}
|
||||||
|
.case__photo-inner { position: absolute; inset: -6%; }
|
||||||
|
.case__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform var(--t-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
/* gradient veil so the tag/number stay legible on any photo */
|
||||||
|
.case__photo::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(7, 7, 11, 0.55), transparent 40%, rgba(7, 7, 11, 0.7));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.case:hover .case__img { transform: scale(1.07); }
|
||||||
|
}
|
||||||
|
.case__photo-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.9rem; left: 0.9rem;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(7, 7, 11, 0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid var(--c-line-strong);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
|
color: var(--c-text);
|
||||||
}
|
}
|
||||||
.case__no { color: var(--case-accent); font-weight: 600; }
|
.case__photo-no {
|
||||||
.case__tag { color: var(--c-text-dim); text-align: right; }
|
position: absolute;
|
||||||
|
bottom: 0.7rem; right: 0.9rem;
|
||||||
|
z-index: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--step-2);
|
||||||
|
color: var(--case-accent);
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* per-case data visual */
|
||||||
.case__viz {
|
.case__viz {
|
||||||
margin: 1.4rem 0 1.6rem;
|
margin: clamp(1.1rem, 2vw, 1.4rem) clamp(1.4rem, 2.5vw, 2rem) 0;
|
||||||
padding: 1rem;
|
padding: 0.9rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.28);
|
||||||
border: 1px solid var(--c-line);
|
border: 1px solid var(--c-line);
|
||||||
}
|
}
|
||||||
.case__bars { display: flex; align-items: flex-end; gap: 8px; height: 84px; }
|
.case__chart { width: 100%; height: auto; display: block; }
|
||||||
.case__bar {
|
.case__chart text {
|
||||||
flex: 1;
|
font-family: var(--font-mono);
|
||||||
height: var(--h);
|
font-size: 9px;
|
||||||
border-radius: 4px 4px 0 0;
|
letter-spacing: 0.02em;
|
||||||
background: linear-gradient(var(--case-accent), color-mix(in srgb, var(--case-accent) 30%, transparent));
|
}
|
||||||
|
.case__legend { color: var(--c-text-faint); }
|
||||||
|
.case__targetlab { font-size: 8px; }
|
||||||
|
.case__bar2 {
|
||||||
transform: scaleY(0);
|
transform: scaleY(0);
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
animation: barGrow 0.85s var(--ease-out) forwards;
|
transform-box: fill-box;
|
||||||
|
animation: caseBarGrow 0.7s var(--ease-out) forwards;
|
||||||
animation-delay: var(--d);
|
animation-delay: var(--d);
|
||||||
}
|
}
|
||||||
@keyframes barGrow {
|
@keyframes caseBarGrow {
|
||||||
0% { transform: scaleY(0); }
|
0% { transform: scaleY(0); }
|
||||||
72% { transform: scaleY(1.04); }
|
72% { transform: scaleY(1.04); }
|
||||||
100% { transform: scaleY(1); }
|
100% { transform: scaleY(1); }
|
||||||
}
|
}
|
||||||
.case__spark { width: 100%; height: 26px; margin-top: 8px; }
|
.case__body {
|
||||||
.case__body { position: relative; z-index: 1; }
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: clamp(1.1rem, 2vw, 1.4rem) clamp(1.4rem, 2.5vw, 2rem) 0;
|
||||||
|
}
|
||||||
.case__problem {
|
.case__problem {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1108,7 +1275,9 @@ body::before {
|
||||||
.case__result { font-size: var(--step-1); margin: 0.7rem 0 0.4rem; }
|
.case__result { font-size: var(--step-1); margin: 0.7rem 0 0.4rem; }
|
||||||
.case__how { font-size: var(--step--1); color: var(--c-text-dim); }
|
.case__how { font-size: var(--step--1); color: var(--c-text-dim); }
|
||||||
.case__metric {
|
.case__metric {
|
||||||
margin-top: 1.4rem;
|
margin-top: auto; /* pin to the bottom so cards align regardless of copy len */
|
||||||
|
margin-inline: clamp(1.4rem, 2.5vw, 2rem);
|
||||||
|
margin-bottom: clamp(1.4rem, 2.5vw, 2rem);
|
||||||
padding-top: 1.2rem;
|
padding-top: 1.2rem;
|
||||||
border-top: 1px solid var(--c-line);
|
border-top: 1px solid var(--c-line);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1126,8 +1295,14 @@ body::before {
|
||||||
/* active-step accent, set by JS as the scroll advances */
|
/* active-step accent, set by JS as the scroll advances */
|
||||||
--loop-accent: #3b82f6;
|
--loop-accent: #3b82f6;
|
||||||
}
|
}
|
||||||
|
/* loop-pin is a flex column-centerer; loop__inner ALSO carries .wrap, so it
|
||||||
|
must NOT override .wrap's constrained width (that was forcing the whole loop
|
||||||
|
— title, kicker and dark card — to full-bleed/touch the left edge). We let
|
||||||
|
.wrap own the width + auto side margins; in this flex context margin-inline
|
||||||
|
auto keeps it centered with equal left/right gutters like every other
|
||||||
|
section. min-width:0 lets the inner grid shrink correctly. */
|
||||||
.loop-pin { min-height: 100svh; display: flex; align-items: center; }
|
.loop-pin { min-height: 100svh; display: flex; align-items: center; }
|
||||||
.loop__inner { width: 100%; }
|
.loop__inner { min-width: 0; }
|
||||||
.loop__stage {
|
.loop__stage {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
@ -1393,68 +1568,185 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
16. TESTIMONIALS
|
16. TESTIMONIALS — interactive carousel
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
.quotes__grid {
|
.carousel {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: clamp(1.5rem, 3vw, 2.5rem);
|
|
||||||
}
|
|
||||||
@media (max-width: 760px) { .quotes__grid { grid-template-columns: 1fr; } }
|
|
||||||
.quote {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: clamp(1.6rem, 3vw, 2.4rem);
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
|
|
||||||
border: 1px solid var(--c-line);
|
border: 1px solid var(--c-line);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 130% at 0% 0%, rgba(59, 130, 246, 0.1), transparent 55%),
|
||||||
|
radial-gradient(120% 130% at 100% 100%, rgba(16, 185, 129, 0.08), transparent 55%),
|
||||||
|
linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
|
||||||
|
padding: clamp(1.8rem, 4vw, 3.2rem);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition:
|
|
||||||
border-color var(--t-mid) var(--ease-out),
|
|
||||||
box-shadow var(--t-slow) var(--ease-out),
|
|
||||||
transform var(--t-mid) var(--ease-out);
|
|
||||||
}
|
}
|
||||||
.quote::before {
|
.carousel:focus-visible { outline-offset: 6px; }
|
||||||
content: "";
|
.carousel__stage {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: radial-gradient(120% 80% at 100% 0%, rgba(139, 92, 246, 0.12), transparent 60%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--t-slow) var(--ease-out);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
.quote:hover {
|
|
||||||
border-color: var(--c-line-strong);
|
|
||||||
box-shadow: 0 24px 60px -28px rgba(139, 92, 246, 0.5);
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
.quote:hover::before { opacity: 1; }
|
|
||||||
}
|
|
||||||
.quote__mark {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
font-size: 4rem;
|
min-height: clamp(220px, 30vw, 280px);
|
||||||
line-height: 0.5;
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.carousel__mark {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.35em;
|
||||||
|
left: -0.05em;
|
||||||
|
font-size: clamp(6rem, 16vw, 11rem);
|
||||||
|
line-height: 1;
|
||||||
color: var(--violet);
|
color: var(--violet);
|
||||||
opacity: 0.5;
|
opacity: 0.16;
|
||||||
transition: opacity var(--t-mid) var(--ease-out);
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
@media (hover: hover) and (pointer: fine) {
|
.carousel__slide {
|
||||||
.quote:hover .quote__mark { opacity: 0.85; }
|
grid-area: 1 / 1;
|
||||||
|
max-width: 60ch;
|
||||||
}
|
}
|
||||||
.quote__text { position: relative; font-size: var(--step-1); font-weight: 500; line-height: 1.35; letter-spacing: -0.01em; margin-top: 1rem; }
|
.carousel__quote {
|
||||||
.quote__by {
|
font-size: var(--step-2);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.carousel__by {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
gap: 1rem;
|
||||||
margin-top: 1.4rem;
|
margin-top: clamp(1.6rem, 3vw, 2.4rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.carousel__avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px; height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--step-0);
|
||||||
|
color: #fff;
|
||||||
|
background: var(--grad-btn);
|
||||||
|
box-shadow: 0 6px 20px -8px rgba(139, 92, 246, 0.6);
|
||||||
|
}
|
||||||
|
.carousel__id { display: flex; flex-direction: column; gap: 0.1rem; margin-right: auto; }
|
||||||
|
.carousel__name { font-weight: 700; font-size: var(--step-0); }
|
||||||
|
.carousel__role {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
color: var(--c-text-faint);
|
color: var(--c-text-faint);
|
||||||
}
|
}
|
||||||
.quote__rule { width: 24px; height: 1px; background: var(--violet); }
|
.carousel__chip {
|
||||||
.partners { margin-top: clamp(2.5rem, 5vw, 4rem); display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; }
|
flex-shrink: 0;
|
||||||
.partners__list { display: flex; gap: 1.5rem; flex-wrap: wrap; }
|
padding: 0.4rem 0.9rem;
|
||||||
.partners__list li { font-weight: 700; color: var(--c-text-dim); }
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--emerald) 45%, transparent);
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6ee7b7; /* emerald-300 — ~7:1 on the dark card */
|
||||||
|
}
|
||||||
|
.carousel__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
margin-top: clamp(1.6rem, 3vw, 2.2rem);
|
||||||
|
padding-top: clamp(1.2rem, 2.5vw, 1.8rem);
|
||||||
|
border-top: 1px solid var(--c-line);
|
||||||
|
}
|
||||||
|
.carousel__arrow {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--c-text);
|
||||||
|
border: 1px solid var(--c-line-strong);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
transition:
|
||||||
|
border-color var(--t-mid) var(--ease-out),
|
||||||
|
background var(--t-mid) var(--ease-out),
|
||||||
|
transform var(--t-press) var(--ease-out);
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.carousel__arrow:hover {
|
||||||
|
border-color: var(--violet);
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.carousel__arrow:active { transform: scale(0.92); }
|
||||||
|
.carousel__dots { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
|
.carousel__dot {
|
||||||
|
width: 9px; height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--c-line-strong);
|
||||||
|
transition:
|
||||||
|
background var(--t-mid) var(--ease-out),
|
||||||
|
transform var(--t-mid) var(--ease-out),
|
||||||
|
box-shadow var(--t-mid) var(--ease-out);
|
||||||
|
}
|
||||||
|
.carousel__dot:hover { background: var(--c-text-faint); transform: scale(1.2); }
|
||||||
|
.carousel__dot.is-active {
|
||||||
|
background: var(--violet);
|
||||||
|
transform: scale(1.35);
|
||||||
|
box-shadow: 0 0 10px rgba(139, 92, 246, 0.7);
|
||||||
|
}
|
||||||
|
/* avatar rail — quick nav across all clients */
|
||||||
|
.carousel__rail {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: clamp(1.6rem, 3vw, 2.2rem);
|
||||||
|
}
|
||||||
|
.carousel__railitem {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.45rem 0.8rem 0.45rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--c-line);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--c-text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
transition:
|
||||||
|
border-color var(--t-mid) var(--ease-out),
|
||||||
|
color var(--t-mid) var(--ease-out),
|
||||||
|
background var(--t-mid) var(--ease-out),
|
||||||
|
transform var(--t-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.carousel__railmono {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.carousel__railitem:hover {
|
||||||
|
border-color: var(--c-line-strong);
|
||||||
|
color: var(--c-text);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.carousel__railitem.is-active {
|
||||||
|
border-color: var(--violet);
|
||||||
|
color: var(--c-text);
|
||||||
|
background: rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
.carousel__railitem.is-active .carousel__railmono {
|
||||||
|
background: var(--grad-btn);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.carousel__railname { display: none; }
|
||||||
|
.carousel__railitem { padding: 0.4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
17. FAQ
|
17. FAQ
|
||||||
|
|
@ -1496,18 +1788,88 @@ body::before {
|
||||||
.faq__a { padding-bottom: clamp(1.1rem, 2.5vw, 1.6rem); max-width: 64ch; color: var(--paper-dim); font-size: var(--step-0); }
|
.faq__a { padding-bottom: clamp(1.1rem, 2.5vw, 1.6rem); max-width: 64ch; color: var(--paper-dim); font-size: var(--step-0); }
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
18. FINAL CTA
|
18. FINAL CTA — dramatic closing moment over a cursor-reactive WebGL field
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
.final { text-align: center; }
|
.final {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
/* taller so the big interactive field has room to breathe */
|
||||||
|
padding-block: clamp(6rem, 12vw, 11rem);
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
/* top/bottom feather so the section blends into the dark canvas above + below */
|
||||||
|
.final::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, var(--c-bg), transparent 16%, transparent 84%, var(--c-bg));
|
||||||
|
}
|
||||||
|
/* readability backdrop directly behind the copy: a soft, blurred dark vignette
|
||||||
|
centred on the text so the kinetic headline + gradient word ALWAYS clear
|
||||||
|
contrast over the bright cursor-reactive field, while the field stays vivid
|
||||||
|
around the edges. */
|
||||||
.final__wrap {
|
.final__wrap {
|
||||||
width: min(100% - var(--gutter) * 2, 760px);
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: min(100% - var(--gutter) * 2, 820px);
|
||||||
|
margin-inline: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: clamp(2rem, 5vw, 4rem) clamp(1.5rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
.final__wrap::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -8% -4%;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(
|
||||||
|
62% 60% at 50% 50%,
|
||||||
|
rgba(7, 7, 11, 0.82),
|
||||||
|
rgba(7, 7, 11, 0.5) 55%,
|
||||||
|
transparent 80%
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.final__h {
|
||||||
|
font-size: var(--step-5);
|
||||||
|
margin: 1.2rem 0;
|
||||||
|
/* "grow?" descender room — the gradient-clipped word must not crop its tail */
|
||||||
|
padding-bottom: 0.16em;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
/* kinetic-type line masks (GSAP SplitText) — descender room like the hero */
|
||||||
|
.final__line { overflow: hidden; padding-bottom: 0.16em; margin-bottom: -0.16em; }
|
||||||
|
.final__char { display: inline-block; will-change: transform; }
|
||||||
|
/* gradient word ("grow?") repainted per-glyph — SplitText breaks the parent
|
||||||
|
.grad clip, so each char carries the gradient itself */
|
||||||
|
.final__char--grad {
|
||||||
|
background-image: var(--grad-text);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.final__sub {
|
||||||
|
font-size: var(--step-1);
|
||||||
|
color: var(--c-text);
|
||||||
|
max-width: 40ch;
|
||||||
|
text-shadow: 0 2px 16px rgba(7, 7, 11, 0.6);
|
||||||
}
|
}
|
||||||
.final__h { font-size: var(--step-5); margin: 1.2rem 0; }
|
|
||||||
.final__sub { font-size: var(--step-1); color: var(--c-text-dim); max-width: 40ch; }
|
|
||||||
.final__cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; margin-top: 2.2rem; }
|
.final__cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; margin-top: 2.2rem; }
|
||||||
|
/* amplified primary button for the closing CTA */
|
||||||
|
.btn--xl {
|
||||||
|
padding: 1.15rem 2.2rem;
|
||||||
|
font-size: var(--step-1);
|
||||||
|
}
|
||||||
|
.btn--accent.btn--xl { box-shadow: 0 14px 50px -10px rgba(139, 92, 246, 0.75); }
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.btn--accent.btn--xl:hover { box-shadow: 0 22px 64px -10px rgba(139, 92, 246, 0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
19. FOOTER
|
19. FOOTER
|
||||||
|
|
@ -1541,7 +1903,11 @@ body::before {
|
||||||
}
|
}
|
||||||
.hero__h1 { visibility: visible !important; }
|
.hero__h1 { visibility: visible !important; }
|
||||||
.hero__stagger { opacity: 1 !important; }
|
.hero__stagger { opacity: 1 !important; }
|
||||||
.case__bar { transform: scaleY(1); animation: none; }
|
/* final CTA kinetic headline: GSAP SplitText never runs, so keep it visible */
|
||||||
|
.final__h { visibility: visible !important; opacity: 1 !important; }
|
||||||
|
/* case viz: bars/lines render fully, no grow/draw */
|
||||||
|
.case__bar2 { transform: scaleY(1) !important; animation: none !important; }
|
||||||
|
.case__img { transform: none !important; }
|
||||||
.hero__video { display: none; } /* poster image only */
|
.hero__video { display: none; } /* poster image only */
|
||||||
/* keep decorative loops fully still, not snapping each 0.001ms */
|
/* keep decorative loops fully still, not snapping each 0.001ms */
|
||||||
.hero__live,
|
.hero__live,
|
||||||
|
|
|
||||||
71
app/page.tsx
|
|
@ -1,9 +1,7 @@
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
SITE,
|
SITE,
|
||||||
services,
|
services,
|
||||||
cases,
|
cases,
|
||||||
testimonials,
|
|
||||||
} from "./content";
|
} from "./content";
|
||||||
|
|
||||||
import SiteHeader from "./components/SiteHeader";
|
import SiteHeader from "./components/SiteHeader";
|
||||||
|
|
@ -13,9 +11,11 @@ import Reveal, { RevealItem } from "./components/Reveal";
|
||||||
import Scoreboard from "./components/Scoreboard";
|
import Scoreboard from "./components/Scoreboard";
|
||||||
import CaseCard, { type CaseData } from "./components/CaseCard";
|
import CaseCard, { type CaseData } from "./components/CaseCard";
|
||||||
import ProcessLoop from "./components/ProcessLoop";
|
import ProcessLoop from "./components/ProcessLoop";
|
||||||
|
import Testimonials from "./components/Testimonials";
|
||||||
import Faq from "./components/Faq";
|
import Faq from "./components/Faq";
|
||||||
import Magnetic from "./components/Magnetic";
|
import Magnetic from "./components/Magnetic";
|
||||||
import SectionDivider from "./components/SectionDivider";
|
import SectionDivider from "./components/SectionDivider";
|
||||||
|
import FinalCTA from "./components/FinalCTA";
|
||||||
|
|
||||||
const TAPE = [
|
const TAPE = [
|
||||||
"Revenue, not vanity metrics",
|
"Revenue, not vanity metrics",
|
||||||
|
|
@ -259,41 +259,8 @@ export default function Page() {
|
||||||
{/* ===== PROCESS — pinned scroll + morphing SVG ===== */}
|
{/* ===== PROCESS — pinned scroll + morphing SVG ===== */}
|
||||||
<ProcessLoop />
|
<ProcessLoop />
|
||||||
|
|
||||||
{/* ===== TESTIMONIALS ===== */}
|
{/* ===== TESTIMONIALS — interactive auto-advancing carousel ===== */}
|
||||||
<section className="quotes frame" aria-label="What clients say">
|
<Testimonials />
|
||||||
<div className="wrap">
|
|
||||||
<header className="sec-head">
|
|
||||||
<p className="kicker">
|
|
||||||
<span className="kicker__dot" />
|
|
||||||
In their words — sample
|
|
||||||
</p>
|
|
||||||
<Reveal as="h2" variant="clip">
|
|
||||||
<span className="display sec-head__title">
|
|
||||||
The number is the point
|
|
||||||
</span>
|
|
||||||
</Reveal>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
<div className="wrap quotes__grid">
|
|
||||||
{testimonials.map((t, i) => (
|
|
||||||
<Reveal as="figure" className="quote" key={i} delay={i * 120}>
|
|
||||||
<span className="quote__mark display" aria-hidden="true">“</span>
|
|
||||||
<blockquote className="quote__text display">{t.quote}</blockquote>
|
|
||||||
<figcaption className="quote__by">
|
|
||||||
<span className="quote__rule" aria-hidden="true" />
|
|
||||||
{t.by}
|
|
||||||
</figcaption>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="wrap partners">
|
|
||||||
<p className="kicker">Partners</p>
|
|
||||||
<ul className="partners__list">
|
|
||||||
<li>Google Partner</li>
|
|
||||||
<li>Meta Business Partner</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ===== FAQ ===== */}
|
{/* ===== FAQ ===== */}
|
||||||
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
|
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
|
||||||
|
|
@ -313,34 +280,8 @@ export default function Page() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ===== FINAL CTA ===== */}
|
{/* ===== FINAL CTA — dramatic cursor-reactive WebGL + kinetic type ===== */}
|
||||||
<section className="final frame" aria-labelledby="final-h">
|
<FinalCTA />
|
||||||
<div className="wrap final__wrap">
|
|
||||||
<p className="kicker">
|
|
||||||
<span className="kicker__dot" />
|
|
||||||
The bottom line
|
|
||||||
</p>
|
|
||||||
<Reveal as="header">
|
|
||||||
<h2 id="final-h" className="display final__h">
|
|
||||||
Ready to <span className="grad">grow?</span>
|
|
||||||
</h2>
|
|
||||||
</Reveal>
|
|
||||||
<p className="final__sub">
|
|
||||||
No long contracts. No vanity reports. Marketing you can measure in
|
|
||||||
sales.
|
|
||||||
</p>
|
|
||||||
<div className="final__cta">
|
|
||||||
<Magnetic strength={0.4}>
|
|
||||||
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book a call">
|
|
||||||
Book a call
|
|
||||||
</Link>
|
|
||||||
</Magnetic>
|
|
||||||
<a href={`mailto:${SITE.email}`} className="btn btn--ghost">
|
|
||||||
or {SITE.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ===== FOOTER ===== */}
|
{/* ===== FOOTER ===== */}
|
||||||
|
|
|
||||||
BIN
audit2/hero-after.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/assets/case-clinic.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/assets/case-fashion.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/assets/case-saas.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/assets/person-1.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/assets/person-2.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/assets/person-3.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/assets/person-4.webp
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/assets/person-5.webp
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/assets/person-6.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |