feat: 'The Feedback Terminal' — bold editorial/financial-broadsheet homepage redesign

This commit is contained in:
Feedback Studios 2026-06-16 06:43:59 +00:00
parent ae0be1c48e
commit 30bed043ec
23 changed files with 1839 additions and 2678 deletions

View file

@ -0,0 +1,103 @@
"use client";
import { useRef, useState } from "react";
/**
* Interactive "before / after" revenue slider the visual proof-of-value
* pattern. A draggable divider wipes a muted "before" chart to reveal a
* surging acid "after" chart. Fully keyboard operable via a real range
* input (arrow keys). The headline result is always visible as text, so
* meaning never depends on the interaction.
*/
export default function BeforeAfter({
before,
after,
caption,
}: {
before: string;
after: string;
caption: string;
}) {
const [pos, setPos] = useState(38);
const wrap = useRef<HTMLDivElement>(null);
const id = `ba-${caption.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
const drag = (clientX: number) => {
const r = wrap.current?.getBoundingClientRect();
if (!r) return;
const p = ((clientX - r.left) / r.width) * 100;
setPos(Math.max(2, Math.min(98, p)));
};
return (
<figure className="ba">
<div
className="ba__stage"
ref={wrap}
onPointerMove={(e) => e.buttons === 1 && drag(e.clientX)}
onPointerDown={(e) => drag(e.clientX)}
>
{/* AFTER (full) — surging line */}
<div className="ba__layer ba__after" aria-hidden="true">
<Chart variant="after" />
<span className="ba__tag ba__tag--after">{after}</span>
</div>
{/* BEFORE (clipped to slider) — flat, muted */}
<div
className="ba__layer ba__before"
style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
aria-hidden="true"
>
<Chart variant="before" />
<span className="ba__tag ba__tag--before">{before}</span>
</div>
{/* divider + accessible control */}
<div className="ba__divider" style={{ left: `${pos}%` }} aria-hidden="true">
<span className="ba__handle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6 4 12l5 6M15 6l5 6-5 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
</div>
<label className="sr-only" htmlFor={id}>
Reveal results for {caption}: drag to compare before and after
</label>
<input
id={id}
className="ba__range"
type="range"
min={2}
max={98}
value={pos}
onChange={(e) => setPos(Number(e.target.value))}
/>
</div>
<figcaption className="ba__cap">{caption}</figcaption>
</figure>
);
}
/** SVG sparkline. "before" = flat/jagged & muted, "after" = steep climb + acid. */
function Chart({ variant }: { variant: "before" | "after" }) {
const after = variant === "after";
const path = after
? "M0 86 C 40 84, 70 80, 110 70 S 190 30, 240 10"
: "M0 70 C 40 72, 70 66, 110 70 S 190 64, 240 62";
return (
<svg
className={`ba__chart ba__chart--${variant}`}
viewBox="0 0 240 100"
preserveAspectRatio="none"
>
<defs>
<linearGradient id={`g-${variant}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={after ? "var(--c-acid)" : "currentColor"} stopOpacity={after ? 0.35 : 0.12} />
<stop offset="1" stopColor={after ? "var(--c-acid)" : "currentColor"} stopOpacity="0" />
</linearGradient>
</defs>
<path d={`${path} L 240 100 L 0 100 Z`} fill={`url(#g-${variant})`} stroke="none" />
<path d={path} fill="none" stroke={after ? "var(--c-acid)" : "currentColor"} strokeWidth={after ? 2.5 : 1.5} strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
);
}

View file

@ -1,67 +0,0 @@
"use client";
/**
* CaseStudies large film-cell cards. Each is a real <article> with the
* generated capsule visual, a problem -> result narrative, the method, and the
* headline metric. Alternating media side creates an editorial rhythm; refined
* scale-on-hover on the image. Images are explicit-sized + lazy-loaded.
*/
import Reveal from "./Reveal";
import { cases } from "../content";
export default function CaseStudies() {
return (
<section id="work" className="cases" aria-label="Case studies">
<div className="wrap">
<Reveal>
<p className="kicker">Case studies</p>
<h2 className="section__title">Proof, not promises.</h2>
<p className="section__lead">
A few of the businesses we&apos;ve grown. Same approach every time:
tie the work to the revenue, then prove it.
</p>
</Reveal>
<div className="cases__list">
{cases.map((c, i) => (
<Reveal key={c.tag} y={60}>
<article className="case hoverable" data-cursor="view work">
<div className="case__media">
<span className="case__tag">{c.tag}</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={c.img}
alt={c.alt}
className="case__img"
width={900}
height={760}
loading={i === 0 ? "eager" : "lazy"}
decoding="async"
/>
</div>
<div className="case__body">
<p className="case__problem">{c.problem}</p>
<h3 className="case__result">
<em>{c.result}</em>
</h3>
<p className="case__how">{c.how}</p>
<p className="case__metric">
<span className="case__metric-num">{c.metricNum}</span>
<span className="case__metric-label">{c.metricLabel}</span>
</p>
</div>
</article>
</Reveal>
))}
</div>
<div className="cases__cta">
<a className="btn btn--ghost hoverable" href="#contact">
<span>View all work </span>
</a>
</div>
</div>
</section>
);
}

View file

@ -1,74 +1,69 @@
"use client";
/**
* CountUp animates a number from 0 to its target when it scrolls into view.
* Uses GSAP + ScrollTrigger, formats with optional prefix/suffix/decimals and
* tabular figures. Under reduced-motion it renders the final value immediately.
* The full value is also written to the DOM on mount so it is correct even if
* JS/animation never runs (accessible + SSR-safe).
*/
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
import { useEffect, useRef, useState } from "react";
type Props = {
value: number;
to: number;
prefix?: string;
suffix?: string;
decimals?: number;
className?: string;
duration?: number;
};
/**
* Count-up that fires once when scrolled into view. Renders the final
* value immediately under reduced motion (and as SSR fallback) so the
* real number is always present for assistive tech and no-JS.
*/
export default function CountUp({
value,
to,
prefix = "",
suffix = "",
decimals = 0,
className = "",
duration = 1600,
}: Props) {
const ref = useRef<HTMLSpanElement>(null);
const format = (n: number) =>
`${prefix}${n.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}${suffix}`;
const [val, setVal] = useState(to);
const done = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.textContent = format(value);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setVal(to);
return;
}
setVal(0);
const node = ref.current;
if (!node) return;
const obj = { n: 0 };
el.textContent = format(0);
const ctx = gsap.context(() => {
gsap.to(obj, {
n: value,
duration: 2,
ease: "power2.out",
scrollTrigger: { trigger: el, start: "top 85%", once: true },
onUpdate: () => {
el.textContent = format(obj.n);
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting || done.current) return;
done.current = true;
const start = performance.now();
const tick = (now: number) => {
const p = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
setVal(to * eased);
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
io.disconnect();
},
});
}, el);
{ threshold: 0.4 }
);
io.observe(node);
return () => io.disconnect();
}, [to, duration]);
return () => ctx.revert();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, prefix, suffix, decimals]);
// Render full value at SSR/first paint; the effect resets to 0 then animates.
return (
<span ref={ref} className={className}>
{format(value)}
<span ref={ref}>
{prefix}
{val.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}
{suffix}
</span>
);
}

View file

@ -1,79 +1,64 @@
"use client";
/**
* Cursor a character cursor with a difference-blend "lens".
*
* A small dot tracks tightly; a larger ring trails with easing and inverts the
* content beneath it (mix-blend-mode: difference). Over interactive elements it
* grows; over elements carrying data-cursor it swaps in a contextual label
* (e.g. "ver"). Disabled on touch / coarse pointers. Native cursor remains as a
* fallback so the page is always usable.
*/
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
/**
* A small "terminal crosshair" cursor that grows into a reading ring over
* interactive targets. Pointer-device only; hidden for touch / reduced
* motion. Purely decorative (aria-hidden) never the only affordance.
*/
export default function Cursor() {
const dot = useRef<HTMLDivElement>(null);
const ring = useRef<HTMLDivElement>(null);
const label = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (window.matchMedia("(pointer: coarse)").matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!dot.current || !ring.current) return;
const fine = window.matchMedia("(pointer: fine)").matches;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!fine || reduce) return;
document.body.classList.add("has-custom-cursor");
const el = ring.current;
const lab = label.current;
if (!el || !lab) return;
const xD = gsap.quickTo(dot.current, "x", { duration: 0.12, ease: "power3" });
const yD = gsap.quickTo(dot.current, "y", { duration: 0.12, ease: "power3" });
const xR = gsap.quickTo(ring.current, "x", { duration: 0.55, ease: "power3" });
const yR = gsap.quickTo(ring.current, "y", { duration: 0.55, ease: "power3" });
let x = window.innerWidth / 2;
let y = window.innerHeight / 2;
let cx = x;
let cy = y;
let raf = 0;
const move = (e: MouseEvent) => {
xD(e.clientX);
yD(e.clientY);
xR(e.clientX);
yR(e.clientY);
const move = (e: PointerEvent) => {
x = e.clientX;
y = e.clientY;
const t = e.target as HTMLElement;
const tgt = t?.closest("a, button, [data-cursor]");
el.dataset.active = tgt ? "1" : "0";
const txt = (tgt as HTMLElement)?.dataset?.cursor;
lab.textContent = txt || "";
el.dataset.hasLabel = txt ? "1" : "0";
};
const over = (e: Event) => {
const t = (e.target as HTMLElement).closest<HTMLElement>(
"a,button,.hoverable,[data-cursor]"
);
if (!t) return;
ring.current?.classList.add("cursor-ring--big");
const text = t.getAttribute("data-cursor");
if (text && label.current) {
label.current.textContent = text;
ring.current?.classList.add("cursor-ring--label");
}
};
const out = (e: Event) => {
const t = (e.target as HTMLElement).closest<HTMLElement>(
"a,button,.hoverable,[data-cursor]"
);
if (!t) return;
ring.current?.classList.remove("cursor-ring--big", "cursor-ring--label");
const loop = () => {
cx += (x - cx) * 0.2;
cy += (y - cy) * 0.2;
el.style.transform = `translate3d(${cx}px, ${cy}px, 0) translate(-50%, -50%)`;
raf = requestAnimationFrame(loop);
};
window.addEventListener("mousemove", move, { passive: true });
document.addEventListener("mouseover", over);
document.addEventListener("mouseout", out);
document.body.classList.add("has-cursor");
window.addEventListener("pointermove", move);
raf = requestAnimationFrame(loop);
return () => {
document.body.classList.remove("has-custom-cursor");
window.removeEventListener("mousemove", move);
document.removeEventListener("mouseover", over);
document.removeEventListener("mouseout", out);
window.removeEventListener("pointermove", move);
cancelAnimationFrame(raf);
document.body.classList.remove("has-cursor");
};
}, []);
return (
<>
<div ref={ring} className="cursor-ring" aria-hidden="true">
<span ref={label} className="cursor-ring__label" />
<div ref={ring} className="cursor" aria-hidden="true" data-active="0">
<span className="cursor__cross" />
<span ref={label} className="cursor__label" />
</div>
<div ref={dot} className="cursor-dot" aria-hidden="true" />
</>
);
}

View file

@ -1,54 +1,45 @@
"use client";
/**
* Faq accessible accordion built on real <button> controls with
* aria-expanded / aria-controls and a CSS grid-rows reveal (no JS height math,
* animates cleanly, collapsible content stays in the DOM for SEO/AEO). The
* answer copy matches the FAQPage JSON-LD in layout.tsx verbatim.
*/
import { useState } from "react";
import Reveal from "./Reveal";
import { faqs } from "../content";
/**
* Accessible accordion built on native <button>. Uses aria-expanded +
* aria-controls; panels are real content (always in DOM for SEO/AEO),
* height-animated via grid-template-rows. One open at a time.
*/
export default function Faq() {
const [open, setOpen] = useState<number | null>(0);
return (
<section id="faq" className="faq" aria-label="Frequently asked questions">
<div className="wrap faq__inner">
<div className="faq__head">
<p className="kicker">FAQ</p>
<h2 className="section__title">
Questions about working with a digital marketing agency
</h2>
</div>
<div className="faq__list">
<div className="faq">
{faqs.map((f, i) => {
const isOpen = open === i;
return (
<div className="faq__item" key={f.q} data-open={isOpen}>
<h3>
<div className="faq__item" key={i}>
<h3 className="faq__h">
<button
type="button"
className="faq__q hoverable"
className="faq__btn"
aria-expanded={isOpen}
aria-controls={`faq-a-${i}`}
id={`faq-q-${i}`}
aria-controls={`faq-panel-${i}`}
id={`faq-btn-${i}`}
onClick={() => setOpen(isOpen ? null : i)}
>
{f.q}
<span className="faq__icon" aria-hidden="true" />
<span className="faq__num">Q{i + 1}</span>
<span className="faq__q">{f.q}</span>
<span className="faq__sign" aria-hidden="true" data-open={isOpen}>
<i /><i />
</span>
</button>
</h3>
<div
className="faq__a"
id={`faq-a-${i}`}
id={`faq-panel-${i}`}
role="region"
aria-labelledby={`faq-q-${i}`}
aria-labelledby={`faq-btn-${i}`}
className="faq__panel"
data-open={isOpen}
>
<div className="faq__a-inner">
<div className="faq__panel-inner">
<p>{f.a}</p>
</div>
</div>
@ -56,7 +47,5 @@ export default function Faq() {
);
})}
</div>
</div>
</section>
);
}

View file

@ -1,117 +0,0 @@
"use client";
/**
* Hero type IS the hero. A display headline at 8-14vw reveals line by line
* (manual masked split via KineticText), the iridescent wave asset floats as a
* tasteful accent layer, and a trust line anchors the claim. Parallax on the
* accent + headline as the hero scrolls out.
*/
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import KineticText from "./KineticText";
import Magnetic from "./Magnetic";
gsap.registerPlugin(ScrollTrigger);
export default function Hero() {
const root = useRef<HTMLElement>(null);
useEffect(() => {
const el = root.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: "power4.out" } });
tl.from(".hero__eyebrow", { y: 18, opacity: 0, duration: 0.9, delay: 0.15 })
.from(".hero__accent", { opacity: 0, scale: 1.06, duration: 1.4 }, 0)
.from(".hero__sub", { y: 22, opacity: 0, duration: 0.9 }, "-=0.2")
.from(".hero__actions > *", { y: 20, opacity: 0, stagger: 0.1, duration: 0.7 }, "-=0.5")
.from(".hero__trust", { opacity: 0, duration: 0.8 }, "-=0.4");
// parallax: accent drifts up, headline lifts + fades as hero scrolls out
gsap.to(".hero__accent", {
yPercent: -18,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true },
});
gsap.to(".hero__display", {
yPercent: 14,
opacity: 0.18,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true },
});
}, el);
return () => ctx.revert();
}, []);
return (
<section className="hero" ref={root}>
{/* iridescent wave accent — decorative texture layer */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/assets/hero-iridescent-1.png"
alt=""
aria-hidden="true"
className="hero__accent"
width={1344}
height={768}
fetchPriority="high"
decoding="async"
/>
<div className="hero__inner wrap">
<p className="hero__eyebrow">
<span className="dot" aria-hidden="true" />
Digital marketing agency
</p>
<h1 className="hero__display">
<KineticText as="span" className="hero__line" text="Marketing that" immediate delay={0.25} />
<KineticText as="span" className="hero__line" text="grows your" immediate delay={0.38} />
<KineticText
as="span"
className="hero__line hero__line--grad"
text="revenue."
immediate
delay={0.5}
highlight={[0, 0]}
/>
</h1>
<p className="hero__sub">
We&apos;re a results-driven digital marketing agency. We run paid, SEO,
and content programs built around <strong>your revenue targets</strong>,
then show you what they returned. Every month.
</p>
<div className="hero__actions">
<Magnetic strength={0.5}>
<a className="btn btn--primary hoverable" href="#contact" data-cursor="let's talk">
<span>Get your growth audit</span>
</a>
</Magnetic>
<a className="hero__secondary hoverable" href="#results">
See the results <span aria-hidden="true"></span>
</a>
</div>
<p className="hero__trust">
<strong>$40M+</strong> in client revenue generated
<span className="hero__trust-sep" aria-hidden="true" />
<strong>50+</strong> brands grown
</p>
</div>
<a className="hero__scroll hoverable" href="#position" aria-label="Scroll to the next section">
<span>scroll</span>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M12 4v16m0 0l-6-6m6 6l6-6" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
</section>
);
}

View file

@ -1,236 +0,0 @@
"use client";
/**
* Iridescence generative WebGL backdrop.
*
* A living interpretation of the brand wave: domain-warped fractal noise mapped
* onto the brand spectrum (violet -> blue -> rose -> emerald) glowing out of a
* near-black field, with a faint rainbow refraction band and animated film
* grain. Drifts on its own and leans toward the cursor. Falls back to a static
* dark CSS gradient when WebGL is unavailable or the user prefers reduced
* motion.
*
* Renders behind everything (fixed, -z) and is purely decorative (aria-hidden).
*/
import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
const VERT = /* glsl */ `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uMouse; // 0..1, smoothed
uniform float uIntensity; // global motion amount (0 for reduced motion)
// Brand palette anchors
const vec3 VIOLET = vec3(0.545, 0.361, 0.965); // #8b5cf6
const vec3 BLUE = vec3(0.231, 0.510, 0.965); // #3b82f6
const vec3 ROSE = vec3(0.957, 0.475, 0.851); // soft pink
const vec3 MINT = vec3(0.204, 0.827, 0.600); // #34d399 emerald
const vec3 BASE = vec3(0.031, 0.031, 0.047); // near-black #08080c
// -- hash / value noise --------------------------------------------------
vec2 hash22(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = dot(hash22(i + vec2(0.0, 0.0)) - 0.5, f - vec2(0.0, 0.0));
float b = dot(hash22(i + vec2(1.0, 0.0)) - 0.5, f - vec2(1.0, 0.0));
float c = dot(hash22(i + vec2(0.0, 1.0)) - 0.5, f - vec2(0.0, 1.0));
float d = dot(hash22(i + vec2(1.0, 1.0)) - 0.5, f - vec2(1.0, 1.0));
return 0.5 + mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float amp = 0.55;
mat2 rot = mat2(0.8, -0.6, 0.6, 0.8);
for (int i = 0; i < 5; i++) {
v += amp * noise(p);
p = rot * p * 2.0;
amp *= 0.5;
}
return v;
}
// film grain
float grain(vec2 uv, float t) {
return fract(sin(dot(uv * (t + 1.0), vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = gl_FragCoord.xy / uResolution.xy;
float aspect = uResolution.x / uResolution.y;
vec2 p = uv;
p.x *= aspect;
float t = uTime * 0.045 * (0.25 + uIntensity);
// mouse parallax (gentle)
vec2 m = (uMouse - 0.5);
p += m * 0.12 * uIntensity;
// domain warping for that liquid cloud feel
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t)));
vec2 r = vec2(
fbm(p + 1.7 * q + vec2(8.3, 2.8) + 0.15 * t),
fbm(p + 1.7 * q + vec2(1.2, 6.5) - 0.12 * t)
);
float f = fbm(p + 2.0 * r);
// the brand spectrum, glowing ADDITIVELY out of a near-black base
vec3 glow = vec3(0.0);
glow += VIOLET * smoothstep(0.35, 0.95, f);
glow += BLUE * smoothstep(0.45, 1.0, r.x) * 0.9;
glow += ROSE * smoothstep(0.62, 1.05, q.y) * 0.5;
glow += MINT * smoothstep(0.66, 1.0, r.y) * 0.55;
// concentrate the light into a soft diagonal wave band (echoes the asset)
float band = smoothstep(0.62, 0.0, abs((uv.x - uv.y) * 1.25 - 0.05 + 0.06 * sin(t * 2.0)));
float field = smoothstep(0.2, 0.85, f);
float energy = (0.35 + 0.65 * band) * field;
vec3 col = BASE + glow * energy * (0.55 + 0.45 * uIntensity);
// faint rainbow refraction along the band
vec3 spectrum = 0.5 + 0.5 * cos(6.2831 * (vec3(0.0, 0.33, 0.67) + (uv.x + uv.y) * 0.7));
col += spectrum * band * 0.05 * (0.4 + uIntensity);
// darken edges so content stays the focus
float vig = smoothstep(1.3, 0.2, distance(uv, vec2(0.5)));
col *= 0.55 + 0.45 * vig;
// keep the base from washing out; clamp the glow softly
col = max(col, BASE * 0.6);
// animated grain to kill banding + add texture
float g = grain(uv, floor(uTime * 12.0) * 0.5);
col += (g - 0.5) * 0.03;
gl_FragColor = vec4(col, 1.0);
}
`;
export default function Iridescence() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let renderer: Renderer;
try {
renderer = new Renderer({
canvas,
dpr: Math.min(window.devicePixelRatio, 1.75),
alpha: false,
antialias: false,
});
} catch {
// WebGL unavailable — CSS fallback (set on the host) remains visible.
canvas.style.display = "none";
return;
}
const gl = renderer.gl;
gl.clearColor(0.031, 0.031, 0.047, 1);
const uniforms = {
uTime: { value: 0 },
uResolution: { value: new Vec2(1, 1) },
uMouse: { value: new Vec2(0.5, 0.5) },
uIntensity: { value: reduce ? 0 : 1 },
};
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms,
});
const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });
const resize = () => {
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setSize(w, h);
uniforms.uResolution.value.set(gl.drawingBufferWidth, gl.drawingBufferHeight);
};
resize();
window.addEventListener("resize", resize);
// smoothed pointer
const target = { x: 0.5, y: 0.5 };
const onMove = (e: PointerEvent) => {
target.x = e.clientX / window.innerWidth;
target.y = 1 - e.clientY / window.innerHeight;
};
window.addEventListener("pointermove", onMove, { passive: true });
let raf = 0;
let running = true;
const start = performance.now();
const loop = (now: number) => {
if (!running) return;
raf = requestAnimationFrame(loop);
uniforms.uTime.value = (now - start) / 1000;
// ease pointer
const m = uniforms.uMouse.value;
m.x += (target.x - m.x) * 0.04;
m.y += (target.y - m.y) * 0.04;
renderer.render({ scene: mesh });
};
if (reduce) {
// render a single frame, then idle
uniforms.uTime.value = 18;
renderer.render({ scene: mesh });
} else {
raf = requestAnimationFrame(loop);
}
// pause when tab hidden (perf + battery)
const onVis = () => {
if (document.hidden) {
running = false;
cancelAnimationFrame(raf);
} else if (!reduce) {
running = true;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
return () => {
running = false;
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onMove);
document.removeEventListener("visibilitychange", onVis);
const ext = gl.getExtension("WEBGL_lose_context");
ext?.loseContext();
};
}, []);
return (
<div className="iridescence" aria-hidden="true">
<canvas ref={ref} className="iridescence__canvas" />
</div>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import SplitType from "split-type";
/**
* Splits the hero H1 into lines + chars and reveals them with a staggered
* mask-up on load the orchestrated "page load" delight moment.
* Under reduced motion the text is simply shown as-is. The real text is
* always in the DOM (SplitType only re-wraps the same characters), so SEO
* and screen readers read the full heading.
*/
export default function KineticHeadline({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
el.style.opacity = "1";
return;
}
const split = new SplitType(el, { types: "lines,words" });
el.style.opacity = "1";
// wrap each line in an overflow-hidden mask
split.lines?.forEach((line) => {
const mask = document.createElement("span");
mask.className = "line-mask";
line.parentNode?.insertBefore(mask, line);
mask.appendChild(line);
});
const tween = gsap.from(split.words || [], {
yPercent: 115,
opacity: 0,
rotateZ: 2,
duration: 1,
ease: "expo.out",
stagger: 0.04,
delay: 0.15,
});
return () => {
tween.kill();
split.revert();
};
}, []);
return (
<h1 ref={ref} className={className} style={{ opacity: 0 }}>
{children}
</h1>
);
}

View file

@ -1,83 +0,0 @@
"use client";
/**
* KineticText word/line masked reveal driven by ScrollTrigger.
*
* Splits the given text into words wrapped in overflow-hidden masks (manual
* spans no premium SplitText plugin) and slides each word up from below as it
* enters the viewport, with a stagger. Renders the text as real, selectable DOM
* so it stays accessible and SEO-safe; animation only transforms (perf-friendly).
*
* With reduced-motion, the text simply appears (no transform).
*/
import { createElement, useEffect, useRef, type ElementType } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
type Props = {
text: string;
as?: ElementType;
className?: string;
/** Delay the whole reveal (s). */
delay?: number;
/** Start animation on mount instead of on scroll (for the hero). */
immediate?: boolean;
/** Mark a word index range as gradient-highlighted. */
highlight?: [number, number];
};
export default function KineticText({
text,
as: Tag = "span",
className = "",
delay = 0,
immediate = false,
highlight,
}: Props) {
const ref = useRef<HTMLElement>(null);
const words = text.split(" ");
useEffect(() => {
const el = ref.current;
if (!el) return;
const inner = gsap.utils.toArray<HTMLElement>(".ktext__in", el);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
gsap.set(inner, { yPercent: 0, opacity: 1 });
return;
}
const ctx = gsap.context(() => {
gsap.set(inner, { yPercent: 118 });
const anim: gsap.TweenVars = {
yPercent: 0,
duration: 1.0,
ease: "power4.out",
stagger: 0.045,
delay,
};
if (!immediate) {
anim.scrollTrigger = { trigger: el, start: "top 88%" };
}
gsap.to(inner, anim);
}, el);
return () => ctx.revert();
}, [delay, immediate]);
const children = words.map((w, i) => {
const hot =
highlight && i >= highlight[0] && i <= highlight[1] ? " ktext__word--grad" : "";
return (
<span className={`ktext__word${hot}`} key={i}>
<span className="ktext__in">{w}</span>
{i < words.length - 1 ? " " : ""}
</span>
);
});
return createElement(Tag, { ref, className: `ktext ${className}` }, children);
}

View file

@ -1,60 +0,0 @@
"use client";
/**
* Magnetic wraps a single interactive child and pulls it toward the cursor
* within a radius, springing back on leave. Disabled for coarse pointers and
* reduced-motion. Purely visual; does not alter semantics of the child.
*/
import { useEffect, useRef, type ReactNode } from "react";
import { gsap } from "gsap";
export default function Magnetic({
children,
strength = 0.4,
className = "",
}: {
children: ReactNode;
strength?: number;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(pointer: coarse)").matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const target = el.firstElementChild as HTMLElement | null;
if (!target) return;
const xTo = gsap.quickTo(target, "x", { duration: 0.5, ease: "elastic.out(1, 0.4)" });
const yTo = gsap.quickTo(target, "y", { duration: 0.5, ease: "elastic.out(1, 0.4)" });
const move = (e: MouseEvent) => {
const r = el.getBoundingClientRect();
const mx = e.clientX - (r.left + r.width / 2);
const my = e.clientY - (r.top + r.height / 2);
xTo(mx * strength);
yTo(my * strength);
};
const leave = () => {
xTo(0);
yTo(0);
};
el.addEventListener("mousemove", move);
el.addEventListener("mouseleave", leave);
return () => {
el.removeEventListener("mousemove", move);
el.removeEventListener("mouseleave", leave);
};
}, [strength]);
return (
<span ref={ref} className={`magnetic ${className}`}>
{children}
</span>
);
}

View file

@ -1,47 +0,0 @@
"use client";
/**
* ProofBar "trusted by" band. A labelled, seamless looping marquee of the
* industries we grow, separated by the brand pill glyph. Pure CSS transform
* animation; pauses under reduced-motion via the stylesheet. Decorative track,
* but the label and items are real text. (Swap industry labels for greyscale
* client logos before launch.)
*/
const items = [
"E-commerce",
"B2B SaaS",
"Clinics",
"Professional services",
"Retail",
"Hospitality",
];
function Row() {
return (
<div className="marquee__row" aria-hidden="true">
{items.map((it) => (
<span className="marquee__item" key={it}>
{it}
<span className="marquee__sep" />
</span>
))}
</div>
);
}
export default function Marquee() {
return (
<section className="proof" aria-label="Industries we work with">
<p className="proof__label">
Trusted by teams that care about the sales number
</p>
<div className="marquee">
<div className="marquee__track">
<Row />
<Row />
</div>
</div>
</section>
);
}

View file

@ -1,44 +0,0 @@
"use client";
/**
* Metrics four headline results with animated count-up on scroll-in.
* The ROAS metric is the single emerald-accented "signature" number.
*/
import Reveal from "./Reveal";
import CountUp from "./CountUp";
import { metrics } from "../content";
export default function Metrics() {
return (
<section id="results" className="metrics" aria-label="Results">
<div className="wrap">
<Reveal>
<p className="kicker">The numbers</p>
<h2 className="section__title">Results we can show you.</h2>
</Reveal>
<div className="metrics__inner">
<Reveal className="metrics__grid" stagger={0.1}>
{metrics.map((m) => (
<div
className={`metric${"accent" in m && m.accent ? " metric--accent" : ""}`}
key={m.label}
>
<p className="metric__num">
<CountUp
value={m.value}
prefix={"prefix" in m ? m.prefix : ""}
suffix={m.suffix}
decimals={"decimals" in m ? m.decimals : 0}
/>
</p>
<p className="metric__label">{m.label}</p>
</div>
))}
</Reveal>
</div>
</div>
</section>
);
}

View file

@ -1,97 +0,0 @@
"use client";
/**
* PillMark the brand imagotype as a living system.
*
* Reconstructs the 4-bar capsule grid (violetblue gradient bar / ink bar +
* blue square / emerald bar) as inline SVG so each pill can be animated
* independently: it assembles on mount, then breathes. Used at hero scale and
* as a compact mark in dividers / footer.
*
* Decorative by default (aria-hidden). Pass `title` to expose it as an image.
*/
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
type Props = {
className?: string;
/** Animate assembly on mount (hero). */
animate?: boolean;
/** Continuous breathing after assembly. */
breathe?: boolean;
title?: string;
};
export default function PillMark({
className = "",
animate = false,
breathe = false,
title,
}: Props) {
const ref = useRef<SVGSVGElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const ctx = gsap.context(() => {
const bars = gsap.utils.toArray<SVGElement>(".pillmark__bar");
if (animate) {
gsap.set(bars, { transformOrigin: "center center" });
gsap.from(bars, {
scaleX: 0,
scaleY: 0.2,
opacity: 0,
duration: 1.1,
ease: "elastic.out(1, 0.75)",
stagger: { each: 0.09, from: "start" },
delay: 0.2,
});
}
if (breathe) {
bars.forEach((bar, i) => {
gsap.to(bar, {
y: i % 2 === 0 ? "+=6" : "-=6",
duration: 2.6 + i * 0.25,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
delay: 1.2 + i * 0.1,
});
});
}
}, el);
return () => ctx.revert();
}, [animate, breathe]);
return (
<svg
ref={ref}
className={`pillmark ${className}`}
viewBox="0 0 1192 1287"
role={title ? "img" : "presentation"}
aria-hidden={title ? undefined : "true"}
aria-label={title}
>
{title ? <title>{title}</title> : null}
<defs>
<linearGradient id="pm-grad" x1="0" y1="0" x2="1192" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#8b5cf6" />
<stop offset="1" stopColor="#3b82f6" />
</linearGradient>
</defs>
{/* top: full-width violet→blue gradient capsule */}
<rect className="pillmark__bar" x="0" y="0" width="1192" height="348.64" rx="174.32" fill="url(#pm-grad)" />
{/* middle row: ink wide capsule + blue square capsule */}
<rect className="pillmark__bar" x="0.02" y="480.64" width="725.02" height="348.64" rx="174.32" fill="#111827" />
<rect className="pillmark__bar" x="843" y="480.28" width="349.04" height="348.98" rx="174.32" fill="#3b82f6" />
{/* bottom: full-width emerald capsule */}
<rect className="pillmark__bar" x="0.04" y="938.21" width="1191.97" height="348.64" rx="174.32" fill="#10b981" />
</svg>
);
}

View file

@ -1,81 +0,0 @@
"use client";
/**
* Process "The Feedback Loop". The intro column pins (sticky) while the four
* steps scroll past; each step lights up as it reaches the viewport center, and
* a serif count in the sticky column advances with it. This is the
* scrollytelling beat. Degrades to a plain stacked list under reduced-motion
* (all steps shown active, no pin behaviour) and on narrow screens.
*/
import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { processSteps } from "../content";
gsap.registerPlugin(ScrollTrigger);
export default function Process() {
const root = useRef<HTMLElement>(null);
const [active, setActive] = useState(0);
useEffect(() => {
const el = root.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setActive(processSteps.length - 1);
return;
}
const ctx = gsap.context(() => {
gsap.utils.toArray<HTMLElement>(".pstep").forEach((step, i) => {
ScrollTrigger.create({
trigger: step,
start: "top 60%",
end: "bottom 60%",
onToggle: (self) => {
if (self.isActive) setActive(i);
},
});
});
}, el);
return () => ctx.revert();
}, []);
return (
<section id="process" className="process" ref={root} aria-label="How it works">
<div className="wrap process__inner">
<div className="process__sticky">
<p className="kicker">How it works</p>
<h2 className="section__title">
The <span className="serif-em">Feedback Loop.</span>
</h2>
<p
className="process__count"
aria-hidden="true"
key={active}
>
0{active + 1}
</p>
</div>
<ol className="process__steps">
{processSteps.map((p, i) => (
<li
className={`pstep${i <= active ? " is-active" : ""}`}
key={p.n}
aria-current={i === active ? "step" : undefined}
>
<div className="pstep__top">
<span className="pstep__n">{p.n}</span>
<h3 className="pstep__name">{p.name}</h3>
</div>
<p className="pstep__desc">{p.desc}</p>
</li>
))}
</ol>
</div>
</section>
);
}

View file

@ -1,54 +1,49 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
import { useEffect, useRef } from "react";
/**
* Reveal generic on-scroll entrance with personality (slide + soft clip),
* not a plain fade-up. With `stagger`, animates direct children in sequence.
* Respects reduced-motion (content shown immediately).
* Lightweight scroll reveal via IntersectionObserver (no GSAP dependency
* so it works even before the smooth-scroll module mounts). Adds .is-in.
* CSS already shows content under reduced motion.
*/
export default function Reveal({
children,
as: Tag = "div",
className = "",
stagger = 0,
y = 44,
delay = 0,
}: {
children: ReactNode;
children: React.ReactNode;
as?: keyof React.JSX.IntrinsicElements;
className?: string;
stagger?: number;
y?: number;
delay?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const targets = stagger > 0 ? Array.from(el.children) : [el];
const ctx = gsap.context(() => {
gsap.from(targets, {
y,
opacity: 0,
filter: "blur(8px)",
duration: 1.05,
ease: "power3.out",
stagger,
scrollTrigger: { trigger: el, start: "top 85%" },
});
}, el);
return () => ctx.revert();
}, [stagger, y]);
const io = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
el.classList.add("is-in");
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -8% 0px" }
);
io.observe(el);
return () => io.disconnect();
}, []);
const Comp = Tag as React.ElementType;
return (
<div ref={ref} className={className}>
<Comp
ref={ref}
className={`reveal ${className}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
</Comp>
);
}

View file

@ -0,0 +1,206 @@
"use client";
import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
/**
* Custom WebGL signature: a flowing "revenue field" animated contour
* lines that rise and surge like a growth chart / topographic gain map,
* rendered as ink hairlines on paper with an acid-green crest threading
* through. Reacts gently to the pointer.
*
* Constraints honored:
* - "use client", all GL created in useEffect, full cleanup
* - no window/document access during SSR/render
* - pauses RAF when the tab is hidden or canvas is offscreen
* - respects prefers-reduced-motion (renders one static frame, no loop)
* - decorative only (aria-hidden); never the sole carrier of meaning
*/
export default function RevenueField() {
const wrap = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = wrap.current;
if (!host) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const renderer = new Renderer({
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio || 1, 2),
});
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
host.appendChild(gl.canvas);
gl.canvas.style.width = "100%";
gl.canvas.style.height = "100%";
gl.canvas.style.display = "block";
const geometry = new Triangle(gl);
const program = new Program(gl, {
uniforms: {
uTime: { value: 0 },
uRes: { value: new Vec2(1, 1) },
uMouse: { value: new Vec2(0.5, 0.5) },
// ink + emerald crest (the hero sits on light paper, so the crest
// uses the deeper "gain" green to stay perceptible, not invisible lime)
uInk: { value: [0.078, 0.075, 0.059] },
uAcid: { value: [0.039, 0.431, 0.278] },
},
vertex: /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`,
fragment: /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uMouse;
uniform vec3 uInk;
uniform vec3 uAcid;
// cheap value noise
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p){
vec2 i = floor(p); vec2 f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
return mix(mix(hash(i), hash(i+vec2(1,0)), u.x),
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), 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;
}
void main() {
vec2 uv = vUv;
float aspect = uRes.x / max(uRes.y, 1.0);
vec2 p = uv;
p.x *= aspect;
// mouse influence — a gentle lift around the pointer
vec2 m = uMouse; m.x *= aspect;
float md = distance(p, m);
float lift = smoothstep(0.6, 0.0, md) * 0.18;
float t = uTime * 0.06;
// a rising surface: base height climbs left->right (growth)
float climb = uv.x * 0.55;
float field = fbm(p * 2.4 + vec2(t, t * 0.4)) + climb + lift;
field += fbm(p * 5.0 - vec2(t * 0.7, 0.0)) * 0.25;
// contour lines from the height field
float lines = abs(fract(field * 9.0) - 0.5);
float w = fwidth(field * 9.0) * 1.2;
float contour = 1.0 - smoothstep(0.0, w, lines);
// crest highlight: the topmost band glows acid (the "gain")
float crest = smoothstep(0.62, 0.86, field) * smoothstep(0.96, 0.7, field);
vec3 col = mix(uInk, uAcid, crest * 0.9);
float alpha = contour * (0.20 + crest * 0.85);
// soft right/top fade so type stays readable, lines vignette off edges
alpha *= smoothstep(0.0, 0.18, uv.x);
alpha *= smoothstep(0.0, 0.14, uv.y) * smoothstep(1.0, 0.82, uv.y);
gl_FragColor = vec4(col, alpha);
}
`,
});
const mesh = new Mesh(gl, { geometry, program });
const resize = () => {
const w = host.clientWidth;
const h = host.clientHeight;
renderer.setSize(w, h);
program.uniforms.uRes.value.set(w, h);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(host);
// pointer (eased)
const target = new Vec2(0.5, 0.5);
const onPointer = (e: PointerEvent) => {
const r = host.getBoundingClientRect();
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
window.addEventListener("pointermove", onPointer);
let raf = 0;
let running = true;
const start = performance.now();
const frame = (now: number) => {
if (!running) return;
const u = program.uniforms;
u.uTime.value = (now - start) / 1000;
(u.uMouse.value as Vec2).x += (target.x - (u.uMouse.value as Vec2).x) * 0.05;
(u.uMouse.value as Vec2).y += (target.y - (u.uMouse.value as Vec2).y) * 0.05;
renderer.render({ scene: mesh });
raf = requestAnimationFrame(frame);
};
// visibility: pause GL when tab hidden or canvas scrolled offscreen
const onVisibility = () => {
if (document.hidden) {
running = false;
cancelAnimationFrame(raf);
} else if (!reduce) {
running = true;
raf = requestAnimationFrame(frame);
}
};
document.addEventListener("visibilitychange", onVisibility);
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !document.hidden && !reduce) {
if (!running) {
running = true;
raf = requestAnimationFrame(frame);
}
} else {
running = false;
cancelAnimationFrame(raf);
}
},
{ threshold: 0 }
);
io.observe(host);
if (reduce) {
// single static frame, no animation loop
program.uniforms.uTime.value = 8;
renderer.render({ scene: mesh });
} else {
raf = requestAnimationFrame(frame);
}
return () => {
running = false;
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onPointer);
document.removeEventListener("visibilitychange", onVisibility);
io.disconnect();
ro.disconnect();
const ext = gl.getExtension("WEBGL_lose_context");
if (ext) ext.loseContext();
if (gl.canvas.parentNode) gl.canvas.parentNode.removeChild(gl.canvas);
};
}, []);
return <div ref={wrap} className="revfield" aria-hidden="true" />;
}

View file

@ -1,37 +0,0 @@
"use client";
/**
* Services deliberately broken editorial grid. Six services on a 6-column
* grid; the first two cards span 3 (breaking the safe 2-col-per-card rhythm),
* the rest span 2. Serif index numerals, spectrum top-line on hover.
*/
import Reveal from "./Reveal";
import { services } from "../content";
export default function Services() {
return (
<section id="services" className="services" aria-label="Services">
<div className="wrap">
<Reveal>
<p className="kicker">What we do</p>
<h2 className="section__title">How we grow your business.</h2>
</Reveal>
<Reveal className="services__grid" stagger={0.08}>
{services.map((s, i) => (
<article
className={`service hoverable${i < 2 ? " service--wide" : ""}`}
key={s.id}
data-cursor="more"
>
<span className="service__index">0{i + 1}</span>
<h3 className="service__name">{s.name}</h3>
<p className="service__desc">{s.desc}</p>
</article>
))}
</Reveal>
</div>
</section>
);
}

View file

@ -1,81 +1,104 @@
"use client";
/**
* SiteHeader fixed top nav. Compacts (adds a glass backdrop) once you scroll
* past the hero, and includes a mobile menu toggle. The wordmark is real text
* ("feedback studios") with the brand mark inline.
*/
import { useEffect, useState } from "react";
import { SITE } from "../content";
import { useEffect, useRef, useState } from "react";
import PillMark from "./PillMark";
const links = [
const NAV = [
{ href: "#services", label: "Services" },
{ href: "#work", label: "Work" },
{ href: "#process", label: "About" },
{ href: "#about", label: "About" },
{ href: "#faq", label: "FAQ" },
];
/** Masthead-style sticky header with a mono "ticker bug" logo. */
export default function SiteHeader() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const headerRef = useRef<HTMLElement>(null);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 80);
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Close the mobile menu on Escape.
// lock body + escape to close mobile nav
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
document.body.style.overflow = open ? "hidden" : "";
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<header
ref={headerRef}
className={`site-header${scrolled ? " is-scrolled" : ""}${open ? " is-open" : ""}`}
>
<div className="site-header__inner">
<a className="brand hoverable" href="#top" aria-label="Feedback Studios, home">
<PillMark className="brand__mark" title="Feedback Studios" />
<span className="brand__word">
feedback<span className="brand__word-2">studios</span>
<header className={`masthead ${scrolled ? "is-scrolled" : ""}`}>
<div className="masthead__bar frame">
<a href="#main" className="logo" aria-label="Feedback Studios — home">
<span className="logo__bug" aria-hidden="true">
FS
</span>
<span className="logo__name">Feedback&nbsp;Studios</span>
<span className="logo__tag" aria-hidden="true">
EST. 26 · REV
</span>
</a>
<nav className="site-nav" aria-label="Primary">
<nav className="masthead__nav" aria-label="Primary">
<ul>
{links.map((l) => (
<li key={l.href}>
<a className="hoverable" href={l.href} onClick={() => setOpen(false)}>
{l.label}
{NAV.map((n) => (
<li key={n.href}>
<a href={n.href}>{n.label}</a>
</li>
))}
</ul>
</nav>
<a
className="btn btn--accent masthead__cta"
href={SITE.booking}
data-cursor="Lets talk"
>
Get a growth audit
</a>
<button
className="masthead__burger"
aria-expanded={open}
aria-controls="mobile-nav"
onClick={() => setOpen((o) => !o)}
>
<span className="sr-only">{open ? "Close menu" : "Open menu"}</span>
<span className="masthead__burger-box" data-open={open} aria-hidden="true">
<i /><i /><i />
</span>
</button>
</div>
<div className="masthead__rule" aria-hidden="true" />
{/* mobile drawer */}
<div id="mobile-nav" className="drawer" data-open={open}>
<nav aria-label="Mobile">
<ul>
{NAV.map((n, i) => (
<li key={n.href} style={{ transitionDelay: `${i * 50 + 60}ms` }}>
<a href={n.href} onClick={() => setOpen(false)}>
<span className="drawer__idx">0{i + 1}</span>
{n.label}
</a>
</li>
))}
</ul>
<a className="btn btn--sm btn--primary hoverable" href="#contact" onClick={() => setOpen(false)}>
<span>Get a growth audit</span>
</a>
</nav>
<button
type="button"
className="site-header__toggle hoverable"
aria-expanded={open}
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen((v) => !v)}
<a
className="btn btn--accent"
href={SITE.booking}
onClick={() => setOpen(false)}
>
<span />
<span />
</button>
Get a growth audit
</a>
</div>
</header>
);

View file

@ -5,46 +5,49 @@ import Lenis from "lenis";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
/**
* Lenis smooth scroll wired into GSAP's ScrollTrigger so scroll-driven
* animation stays in sync. Disabled entirely under reduced-motion.
*/
export default function SmoothScroll() {
useEffect(() => {
// Respect reduced-motion: skip momentum scrolling entirely.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
ScrollTrigger.refresh();
return;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({
duration: 1.15,
smoothWheel: true,
duration: 1.1,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
});
lenis.on("scroll", ScrollTrigger.update);
const ticker = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(ticker);
const raf = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
// Anchor links should route through Lenis for the smooth glide.
// anchor links route through Lenis
const onClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement).closest('a[href^="#"]');
const a = (e.target as HTMLElement)?.closest('a[href^="#"]') as
| HTMLAnchorElement
| null;
if (!a) return;
const id = a.getAttribute("href");
if (!id || id === "#") return;
const target = document.querySelector(id);
if (target) {
const el = document.querySelector(id);
if (!el) return;
e.preventDefault();
lenis.scrollTo(target as HTMLElement, { offset: -20 });
}
lenis.scrollTo(el as HTMLElement, { offset: -80 });
};
document.addEventListener("click", onClick);
return () => {
document.removeEventListener("click", onClick);
gsap.ticker.remove(ticker);
gsap.ticker.remove(raf);
lenis.destroy();
ScrollTrigger.getAll().forEach((t) => t.kill());
};
}, []);

67
app/components/Ticker.tsx Normal file
View file

@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Item = { v: string; up?: boolean; label: string };
/**
* A Bloomberg-style revenue ticker tape. Infinite CSS marquee with live-
* feeling deltas. The visible tape is aria-hidden (decorative motion); a
* single screen-reader summary conveys the same facts statically.
*
* Numbers are illustrative SAMPLES (matches the content note).
*/
const ITEMS: Item[] = [
{ v: "+$2.41M", up: true, label: "Q ARR added" },
{ v: "3.8×", up: true, label: "ROAS" },
{ v: "34%", up: true, label: "CPA" },
{ v: "+217%", up: true, label: "Demo reqs" },
{ v: "+183%", up: true, label: "Organic" },
{ v: "+128", up: true, label: "Bookings/mo" },
{ v: "+52%", up: true, label: "ROAS 90d" },
{ v: "92%", up: true, label: "Retention" },
{ v: "+$40M", up: true, label: "Client revenue" },
];
export default function Ticker() {
const [tick, setTick] = useState(0);
const reduce = useRef(false);
// gentle "last digit flicker" to feel live (skipped on reduced motion)
useEffect(() => {
reduce.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce.current) return;
const id = setInterval(() => setTick((t) => t + 1), 2600);
return () => clearInterval(id);
}, []);
const Row = ({ aria }: { aria: boolean }) => (
<ul className="ticker__row" aria-hidden={aria ? undefined : true}>
{ITEMS.map((it, i) => (
<li className="ticker__item" key={i}>
<span className="ticker__arr" aria-hidden="true">
{it.up ? "▲" : "▼"}
</span>
<span className="ticker__val">{it.v}</span>
<span className="ticker__lab">{it.label}</span>
</li>
))}
</ul>
);
return (
<div className="ticker" data-tick={tick % 2}>
{/* SR-only static summary of the same data */}
<p className="sr-only">
Sample results across recent client programs: 3.8 times average return
on ad spend, plus 217 percent qualified demo requests, plus 183 percent
organic traffic, 92 percent client retention, and over 40 million
dollars in client revenue generated.
</p>
<div className="ticker__track" aria-hidden="true">
<Row aria />
<Row aria />
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -92,7 +92,8 @@ export default function RootLayout({
return (
<html lang="en">
<head>
{/* Satoshi (workhorse) + Instrument Serif (editorial accent) */}
{/* Satoshi (workhorse sans) + Newsreader (editorial display, in
globals via @import) + Spline Sans Mono (data/labels). */}
<link rel="preconnect" href="https://api.fontshare.com" crossOrigin="" />
<link
rel="stylesheet"
@ -100,12 +101,8 @@ export default function RootLayout({
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap"
/>
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="theme-color" content="#08080c" />
<meta name="theme-color" content="#f4f1ea" />
</head>
<body>
<StructuredData />

View file

@ -1,173 +1,424 @@
import Iridescence from "./components/Iridescence";
import SiteHeader from "./components/SiteHeader";
import Hero from "./components/Hero";
import Marquee from "./components/Marquee";
import Reveal from "./components/Reveal";
import KineticText from "./components/KineticText";
import Services from "./components/Services";
import Metrics from "./components/Metrics";
import CaseStudies from "./components/CaseStudies";
import Process from "./components/Process";
import Faq from "./components/Faq";
import PillMark from "./components/PillMark";
import Magnetic from "./components/Magnetic";
import { testimonials } from "./content";
import Link from "next/link";
import Image from "next/image";
import {
SITE,
services,
metrics,
cases,
processSteps,
testimonials,
} from "./content";
export default function Home() {
import SiteHeader from "./components/SiteHeader";
import RevenueField from "./components/RevenueField";
import KineticHeadline from "./components/KineticHeadline";
import Ticker from "./components/Ticker";
import CountUp from "./components/CountUp";
import Reveal from "./components/Reveal";
import BeforeAfter from "./components/BeforeAfter";
import Faq from "./components/Faq";
const PROOF = [
"E-commerce",
"B2B SaaS",
"Clinics",
"Professional services",
"DTC brands",
"Marketplaces",
];
const beforeAfter = [
{ before: "Flat sales", after: "+52% ROAS" },
{ before: "No pipeline", after: "+217% demos" },
{ before: "Empty calendar", after: "+128 booked/mo" },
];
export default function Page() {
return (
<>
<Iridescence />
<SiteHeader />
<main id="main">
<span id="top" />
<Hero />
{/* =========================================================
HERO the masthead front page
========================================================= */}
<section className="hero frame" aria-labelledby="hero-h1">
<div className="hero__field">
<RevenueField />
</div>
{/* SOCIAL PROOF BAR */}
<Marquee />
{/* dateline / edition strip */}
<div className="hero__dateline">
<span>The Revenue Edition</span>
<span aria-hidden="true">/</span>
<span>No. 01</span>
<span aria-hidden="true">/</span>
<span>Digital marketing agency</span>
</div>
<div className="rule rule--strong" />
{/* POSITIONING / PROBLEM — big editorial statement */}
<section id="position" className="positioning" aria-label="Our positioning">
<div className="wrap positioning__grid">
<Reveal>
<p className="kicker">Why we exist</p>
<p className="positioning__statement">
Most marketing budgets buy{" "}
<span className="muted">activity</span>, not{" "}
<span className="serif-em">outcomes.</span>
<div className="hero__grid">
<div className="hero__lead">
<p className="kicker">
<span className="kicker__dot" />
Marketing, priced in revenue
</p>
</Reveal>
<Reveal className="positioning__aside" y={40}>
<span className="serif-em">We started to fix that.</span>
Dashboards fill up with impressions and &ldquo;engagement&rdquo;
while the sales number sits still. Every campaign we run is built
to move revenue, and we report on it the way your CFO would.
</Reveal>
</div>
</section>
{/* SERVICES — broken grid */}
<Services />
{/* RESULTS — animated count-up */}
<Metrics />
{/* CASE STUDIES — large film-cell cards */}
<CaseStudies />
{/* PROCESS — pinned scrollytelling */}
<Process />
{/* TESTIMONIALS + PARTNERS */}
<section id="voices" className="tmonials" aria-label="What clients say">
<div className="wrap">
<Reveal>
<p className="kicker">In their words</p>
<h2 className="section__title">
The number is the <span className="serif-em">whole point.</span>
</h2>
</Reveal>
<Reveal className="tmonials__grid" stagger={0.12}>
{testimonials.map((t) => (
<figure className="tmonial" key={t.by}>
<span className="tmonial__mark" aria-hidden="true">
&ldquo;
<KineticHeadline className="display hero__h1">
<span id="hero-h1">
Marketing that grows your <span className="cash">revenue.</span>
</span>
<blockquote>
<p className="tmonial__quote">{t.quote}</p>
</blockquote>
<figcaption className="tmonial__by">{t.by}</figcaption>
</figure>
))}
</Reveal>
</KineticHeadline>
</div>
<Reveal className="partners">
<span className="partners__label">Partners</span>
<span className="partner">Google Partner</span>
<span className="partner">Meta Business Partner</span>
<div className="hero__side">
<p className="hero__sub">
We&apos;re a results-driven digital marketing agency. We run
paid, SEO, and content programs built around your revenue
targets, then show you what they returned. Every month.
</p>
<div className="hero__cta">
<Link
href={SITE.booking}
className="btn btn--accent"
data-cursor="Book it"
>
Get your growth audit
</Link>
<a href="#work" className="btn btn--ghost">
See the results <span className="arrow" aria-hidden="true"></span>
</a>
</div>
</div>
</div>
{/* hero stat slab */}
<div className="hero__slab">
<div className="hero__stat">
<span className="hero__stat-num display">$40M+</span>
<span className="hero__stat-lab">in client revenue generated</span>
</div>
<div className="hero__stat">
<span className="hero__stat-num display">50+</span>
<span className="hero__stat-lab">brands grown</span>
</div>
<div className="hero__stat hero__stat--live">
<span className="hero__live" aria-hidden="true" />
<span className="hero__stat-lab">
Live revenue feed sample data
</span>
</div>
</div>
</section>
{/* =========================================================
TICKER kinetic social proof tape
========================================================= */}
<section data-invert className="tape" aria-label="Sample results ticker">
<Ticker />
</section>
{/* =========================================================
SOCIAL PROOF / industries
========================================================= */}
<section className="proof frame" aria-label="Who we work with">
<div className="wrap proof__wrap">
<p className="kicker proof__label">
Trusted by teams that care about the sales number
</p>
<ul className="proof__list">
{PROOF.map((p) => (
<li key={p} className="proof__item">
{p}
</li>
))}
</ul>
</div>
</section>
{/* =========================================================
POSITIONING / PROBLEM the manifesto spread
========================================================= */}
<section data-invert className="manifesto frame" aria-labelledby="man-h">
<div className="wrap manifesto__wrap">
<p className="kicker">
<span className="kicker__dot" />
The problem with most agencies
</p>
<Reveal as="p" className="manifesto__lead display" >
<span id="man-h">
Most budgets buy <span className="strike">activity</span>, not
outcomes. Dashboards fill up with impressions while the sales
number <span className="cash">sits still.</span>
</span>
</Reveal>
<Reveal as="p" className="manifesto__body" delay={120}>
We started Feedback Studios to fix that. Every campaign we run is
built to move revenue, and we report on it the way your CFO
would. No vanity metrics. No mystery. Just the number that pays
the bills.
</Reveal>
</div>
</section>
{/* FAQ */}
<Faq />
{/* FINAL CTA */}
<section id="contact" className="cta" aria-label="Get in touch">
<span className="cta__glow" aria-hidden="true" />
<Reveal className="wrap cta__wrap">
<PillMark className="cta__mark" animate breathe />
<h2 className="cta__title">
<KineticText as="span" text="Ready to" />{" "}
<KineticText
as="span"
className="cta__title-grad"
text="grow?"
highlight={[0, 0]}
/>
{/* =========================================================
SERVICES the index / ledger
========================================================= */}
<section id="services" className="services frame" aria-labelledby="svc-h">
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
Services / 06
</p>
<h2 id="svc-h" className="display sec-head__title">
How we grow your business
</h2>
<p className="cta__lead">
</header>
<ol className="ledger">
{services.map((s, i) => (
<li key={s.id} className="ledger__row" data-cursor="">
<span className="ledger__no">{String(i + 1).padStart(2, "0")}</span>
<h3 className="ledger__name display">{s.name}</h3>
<p className="ledger__desc">{s.desc}</p>
<span className="ledger__arrow" aria-hidden="true"></span>
</li>
))}
</ol>
</div>
</section>
{/* =========================================================
METRICS count-up scoreboard (inverted)
========================================================= */}
<section data-invert className="score frame" aria-labelledby="score-h">
<div className="wrap">
<header className="sec-head sec-head--center">
<p className="kicker">
<span className="kicker__dot" />
The scoreboard sample data
</p>
<h2 id="score-h" className="display sec-head__title">
Numbers we report on
</h2>
</header>
<dl className="score__grid">
{metrics.map((m) => (
<div className="score__cell" key={m.label}>
<dt className="sr-only">{m.label}</dt>
<dd className={`score__num display ${"accent" in m && m.accent ? "is-accent" : ""}`}>
<CountUp
to={m.value}
prefix={"prefix" in m ? m.prefix : ""}
suffix={m.suffix}
decimals={"decimals" in m ? m.decimals : 0}
/>
</dd>
<p className="score__lab" aria-hidden="true">{m.label}</p>
</div>
))}
</dl>
</div>
</section>
{/* =========================================================
CASE STUDIES proof spreads w/ before/after sliders
========================================================= */}
<section id="work" className="work frame" aria-labelledby="work-h">
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
Selected work sample
</p>
<h2 id="work-h" className="display sec-head__title">
Proof, not promises
</h2>
</header>
<div className="work__list">
{cases.map((c, i) => (
<article className="case" key={c.tag}>
<div className="case__index">
<span className="case__no">{String(i + 1).padStart(2, "0")}</span>
<span className="case__tag">{c.tag}</span>
</div>
<div className="case__body">
<p className="case__problem">
<span className="case__problem-k">Before:</span> {c.problem}
</p>
<p className="case__result display">{c.result}</p>
<p className="case__how">{c.how}</p>
</div>
<div className="case__visual">
<BeforeAfter
before={beforeAfter[i].before}
after={beforeAfter[i].after}
caption={c.tag}
/>
{/* optional brand asset, lazy + sized to avoid CLS */}
<Image
src={c.img}
alt={c.alt}
width={1200}
height={896}
className="case__img"
loading="lazy"
sizes="(max-width: 900px) 90vw, 40vw"
/>
<div className="case__metric">
<span className="case__metric-num display">{c.metricNum}</span>
<span className="case__metric-lab">{c.metricLabel}</span>
</div>
</div>
</article>
))}
</div>
<div className="work__cta">
<a href={SITE.booking} className="btn btn--ghost">
View all work <span className="arrow" aria-hidden="true"></span>
</a>
</div>
</div>
</section>
{/* =========================================================
PROCESS The Feedback Loop
========================================================= */}
<section data-invert id="about" className="loop frame" aria-labelledby="loop-h">
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
The Feedback Loop
</p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</header>
<ol className="loop__grid">
{processSteps.map((p) => (
<li className="loop__step" key={p.n}>
<span className="loop__n display">{p.n}</span>
<h3 className="loop__name">{p.name}</h3>
<p className="loop__desc">{p.desc}</p>
</li>
))}
</ol>
</div>
</section>
{/* =========================================================
TESTIMONIALS pull quotes
========================================================= */}
<section className="quotes frame" aria-label="What clients say">
<div className="wrap quotes__grid">
{testimonials.map((t, i) => (
<Reveal as="figure" className="quote" key={i} delay={i * 100}>
<blockquote className="quote__text display">
&ldquo;{t.quote}&rdquo;
</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
========================================================= */}
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
<div className="wrap faq-sec__wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
FAQ
</p>
<h2 id="faq-h" className="display sec-head__title">
Questions about working with a digital marketing agency
</h2>
</header>
<Faq />
</div>
</section>
{/* =========================================================
FINAL CTA
========================================================= */}
<section className="final frame" aria-labelledby="final-h">
<div className="wrap final__wrap">
<p className="kicker">
<span className="kicker__dot" />
The bottom line
</p>
<h2 id="final-h" className="display final__h">
Ready to <span className="cash">grow?</span>
</h2>
<p className="final__sub">
No long contracts. No vanity reports. Marketing you can measure in
sales.
</p>
<Magnetic strength={0.4}>
<a
className="btn btn--primary btn--lg hoverable"
href="https://cal.feedback-studios.com"
data-cursor="book a call"
>
<span>Book a call</span>
<div className="final__cta">
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book a call">
Book a call
</Link>
<a href={`mailto:${SITE.email}`} className="btn btn--ghost">
or {SITE.email}
</a>
</Magnetic>
<p className="cta__mail">
or email{" "}
<a className="hoverable" href="mailto:hello@feedbackstudios.com">
hello@feedbackstudios.com
</a>
</p>
</Reveal>
</div>
</div>
</section>
</main>
<footer className="site-footer">
<div className="wrap site-footer__inner">
<div className="site-footer__brand">
<span className="brand__word brand__word--lg">
feedback<span className="brand__word-2">studios</span>
</span>
<p className="site-footer__tag">
A results-driven digital marketing agency. We build paid, SEO and
content programs around your revenue.
{/* =========================================================
FOOTER the colophon
========================================================= */}
<footer data-invert className="colophon frame" aria-label="Footer">
<div className="wrap">
<div className="colophon__top">
<div className="colophon__brand">
<span className="logo__bug" aria-hidden="true">FS</span>
<p className="colophon__line">
{SITE.name}. {SITE.tagline}
</p>
</div>
<div className="site-footer__col">
<h2>Sitemap</h2>
<nav aria-label="Footer">
<a className="hoverable" href="#services">Services</a>
<a className="hoverable" href="#work">Work</a>
<a className="hoverable" href="#process">About</a>
<a className="hoverable" href="#faq">FAQ</a>
<a className="hoverable" href="#contact">Contact</a>
<nav className="colophon__nav" aria-label="Footer">
<ul>
<li><a href="#services">Services</a></li>
<li><a href="#work">Work</a></li>
<li><a href="#about">About</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href={`mailto:${SITE.email}`}>Contact</a></li>
</ul>
<ul>
<li><a href="https://www.linkedin.com" rel="noopener">LinkedIn</a></li>
<li><a href={SITE.booking}>Book a call</a></li>
<li><a href="#main">Privacy</a></li>
<li><a href="#main">Terms</a></li>
</ul>
</nav>
</div>
<div className="site-footer__col">
<h2>Connect</h2>
<nav aria-label="Social and legal">
<a className="hoverable" href="https://www.linkedin.com" rel="noopener">LinkedIn</a>
<a className="hoverable" href="mailto:hello@feedbackstudios.com">Email</a>
<a className="hoverable" href="#">Privacy</a>
<a className="hoverable" href="#">Terms</a>
</nav>
</div>
<p className="site-footer__legal">
<span>© 2026 Feedback Studios</span>
<span>Marketing that grows your revenue.</span>
<div className="rule" />
<div className="colophon__bottom">
<p>© 2026 {SITE.name}. All rights reserved.</p>
<p className="colophon__note">
Metrics and case studies shown are illustrative samples.
</p>
</div>
</div>
</footer>
</>
);