fix: fill empty sections + polish (loop visual panels, countup final-state, testimonials, contrast, trusted-by logos, button)

This commit is contained in:
Feedback Studios 2026-06-16 08:07:17 +00:00
parent d5ad025607
commit 9e2fd18cb7
50 changed files with 845 additions and 62 deletions

View file

@ -1,13 +1,18 @@
"use client";
/**
* Animated number that counts up when it enters the viewport (once).
* Animated number that counts up when it enters the viewport.
* - The FINAL value is the reliable resting state: it is rendered on the
* server / before JS hydrates and under prefers-reduced-motion, so the number
* NEVER persists at 0 (no "$0M+" looking-broken state, even with no JS).
* - When motion is allowed and the element is below the fold, the animation
* resets to 0 once and counts up as it scrolls into view. If the element is
* already in (or above) the viewport on mount, it animates immediately.
* - Motion `animate()` drives a raw value; we format with prefix/suffix/decimals.
* - prefers-reduced-motion: jumps straight to the final value.
* - Real number text stays in the DOM for SEO / screen readers.
*/
import { useEffect, useRef, useState } from "react";
import { animate, useInView, useReducedMotion } from "motion/react";
import { animate, useReducedMotion } from "motion/react";
import { EASE_OUT } from "./motion";
export default function CountUp({
@ -24,23 +29,61 @@ export default function CountUp({
duration?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" });
const reduce = useReducedMotion();
const [val, setVal] = useState(0);
// Resting state = the FINAL value. SSR + no-JS + reduced-motion all show this.
const [val, setVal] = useState(to);
useEffect(() => {
if (!inView) return;
const el = ref.current;
if (!el) return;
// Reduced motion: leave the final value in place, never animate.
if (reduce) {
setVal(to);
return;
}
const controls = animate(0, to, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setVal(v),
});
return () => controls.stop();
}, [inView, to, duration, reduce]);
let controls: ReturnType<typeof animate> | null = null;
let started = false;
const run = () => {
if (started) return;
started = true;
// Reset to 0 only at the moment we start, so the count-up is visible —
// but we never SIT at 0 (the value was the final number until now).
setVal(0);
controls = animate(0, to, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setVal(v),
});
};
// Trigger on mount if already in or above the viewport (handles refresh
// mid-page, anchor jumps, and SSR-then-hydrate where the section is visible).
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (rect.top < vh * 0.85) {
run();
return () => controls?.stop();
}
// Otherwise wait until it scrolls into view.
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
run();
io.disconnect();
}
},
{ rootMargin: "0px 0px -15% 0px" }
);
io.observe(el);
return () => {
io.disconnect();
controls?.stop();
};
}, [to, duration, reduce]);
const display = val.toLocaleString("en-US", {
minimumFractionDigits: decimals,

View file

@ -90,12 +90,14 @@ export default function Hero() {
});
gsap.set(".hero__h1", { autoAlpha: 1 });
gsap.set(".hero__line", { overflow: "hidden" });
// Fast, tight cascade so the FULL headline — including the last word
// ("revenue.") — finishes well within ~600ms and is never left hidden.
gsap.from(split.words, {
yPercent: 118,
duration: 1.15,
duration: 0.5,
ease: "emilOut",
stagger: 0.045,
delay: 0.2,
stagger: 0.025,
delay: 0.12,
});
// 2) Eyebrow, sub, CTAs, trust — lift + blur mask, tight stagger.

View file

@ -3,11 +3,17 @@
/**
* PROCESS "The Feedback Loop" as pinned scroll storytelling.
* - GSAP ScrollTrigger pins the panel while the user scrolls through 4 steps.
* - A single SVG glyph MORPHS between 4 shapes (MorphSVGPlugin) magnifier
* (Audit) -> route/plan -> bolt (Execute) -> chart (Report) the animated-SVG
* moment for this section.
* - The LEFT column is a dark "stage card" (matching the work-cards' visual
* language) that floats over the cream paper. Inside it a single SVG glyph
* MORPHS between 4 shapes (MorphSVGPlugin) AND a per-step VISUAL PANEL swaps:
* Audit -> a "revenue leak" line chart
* Plan -> a channel x objective mini-matrix
* Execute -> rising animated bars
* Report -> a metric card
* This kills the empty cream void and gives the section weight.
* - A progress rail + numbered steps light up in sync (scrub).
* - prefers-reduced-motion: no pin, no morph; steps render as a static list.
* - prefers-reduced-motion: no pin, no morph; panels render statically (all
* visible, no blur) and steps render as a legible static list.
*/
import { useEffect, useRef } from "react";
// Side-effect import registers ScrollTrigger + MorphSVGPlugin; gsap exposes both.
@ -26,6 +32,8 @@ const SHAPES = [
"M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14",
];
const ACCENTS = ["#3b82f6", "#8b5cf6", "#10b981", "#7c3aed"];
export default function ProcessLoop() {
const root = useRef<HTMLElement>(null);
@ -39,29 +47,37 @@ export default function ProcessLoop() {
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
if (!morph) return;
const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
const panels = gsap.utils.toArray<HTMLElement>(".loop-panel");
const total = SHAPES.length;
const setActive = (idx: number) => {
steps.forEach((s, j) => s.classList.toggle("is-active", j === idx));
panels.forEach((p, j) => p.classList.toggle("is-active", j === idx));
el.style.setProperty("--loop-accent", ACCENTS[idx] ?? ACCENTS[0]);
};
// One pinned, scrubbed timeline drives the morph + rotation + rail fill.
// Trimmed end length (was innerHeight*(total-0.2)) so the pin is shorter
// and the section no longer amplifies the emptiness.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".loop-pin",
start: "top top",
end: () => `+=${window.innerHeight * (total - 0.2)}`,
end: () => `+=${window.innerHeight * (total - 1.85)}`,
pin: true,
scrub: 0.6,
anticipatePin: 1,
onUpdate: (self) => {
// Robust, independent active-step highlight tied to raw progress.
const idx = Math.min(
total - 1,
Math.floor(self.progress * total + 0.0001)
);
steps.forEach((s, j) => s.classList.toggle("is-active", j === idx));
setActive(idx);
},
},
});
steps[0]?.classList.add("is-active");
setActive(0);
const counter = el.querySelector<HTMLElement>(".loop__count-cur");
const node = el.querySelector<HTMLElement>(".loop-rail__node");
@ -116,18 +132,137 @@ export default function ProcessLoop() {
</header>
<div className="loop__stage">
{/* morphing glyph */}
<div className="loop-glyph" aria-hidden="true">
<svg viewBox="0 0 100 100">
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
<defs>
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="50%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
</svg>
{/* dark stage card floats the glyph + per-step visual over the
cream paper (mirrors how the work-cards float over the dark) */}
<div className="loop-card" aria-hidden="true">
<div className="loop-card__glow" />
{/* morphing glyph */}
<div className="loop-glyph">
<svg viewBox="0 0 100 100">
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
<defs>
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="50%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
</svg>
</div>
{/* per-step visual panels — only the active one is shown */}
<div className="loop-panels">
{/* 01 AUDIT — "revenue leak" line chart (drops off) */}
<figure className="loop-panel is-active" data-step="audit">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Revenue leak detected
</figcaption>
<svg className="loop-panel__svg" viewBox="0 0 240 96" preserveAspectRatio="none">
<defs>
<linearGradient id="leakFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.32" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0,40 L48,32 L96,38 L120,26 L144,58 L192,74 L240,82 L240,96 L0,96 Z"
fill="url(#leakFill)"
/>
<polyline
points="0,40 48,32 96,38 120,26 144,58 192,74 240,82"
fill="none"
stroke="#3b82f6"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* leak marker */}
<circle cx="120" cy="26" r="3.5" fill="#10b981" />
<circle cx="144" cy="58" r="4.5" fill="none" stroke="#ef6a6a" strokeWidth="2" />
</svg>
<p className="loop-panel__note">
<span className="loop-panel__lab">$14k/mo</span> wasted spend
</p>
</figure>
{/* 02 PLAN — channel x objective mini-matrix */}
<figure className="loop-panel" data-step="plan">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Channel × objective mix
</figcaption>
<div className="loop-matrix">
<span className="loop-matrix__h" />
<span className="loop-matrix__h">Reach</span>
<span className="loop-matrix__h">Leads</span>
<span className="loop-matrix__h">Sales</span>
{(
[
["Paid", 1, 2, 3],
["SEO", 2, 3, 2],
["Content", 3, 2, 1],
] as const
).flatMap((row) => [
<span className="loop-matrix__r" key={`${row[0]}-l`}>
{row[0]}
</span>,
...[row[1], row[2], row[3]].map((lvl, ci) => (
<span
className="loop-matrix__c"
data-lvl={lvl}
key={`${row[0]}-${ci}`}
/>
)),
])}
</div>
</figure>
{/* 03 EXECUTE — rising animated bars */}
<figure className="loop-panel" data-step="execute">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Live optimization
</figcaption>
<div className="loop-bars">
{[34, 52, 46, 70, 84, 96].map((h, i) => (
<span
className="loop-bar"
style={{ ["--h" as string]: `${h}%`, ["--i" as string]: i }}
key={i}
/>
))}
</div>
<p className="loop-panel__note">
<span className="loop-panel__lab">+38%</span> ROAS, week over week
</p>
</figure>
{/* 04 REPORT — metric card */}
<figure className="loop-panel" data-step="report">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Monthly report
</figcaption>
<div className="loop-metric">
<span className="loop-metric__num display">+183%</span>
<span className="loop-metric__lab">revenue vs. last quarter</span>
<span className="loop-metric__trend">
<svg viewBox="0 0 80 24" preserveAspectRatio="none">
<polyline
points="0,20 16,18 32,12 48,13 64,6 80,2"
fill="none"
stroke="#10b981"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
<em>tied to pipeline</em>
</span>
</div>
</figure>
</div>
</div>
{/* steps + progress rail */}

View file

@ -109,6 +109,10 @@ export default function Scoreboard() {
<line className="score__baseline" x1="0" y1="4" x2="600" y2="4" />
</svg>
</div>
{/* 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>
</div>
</section>
);

View file

@ -104,12 +104,12 @@ export const testimonials = [
{
quote:
"We were spending $20k a month on ads with nothing to show. Six months later, marketing is our most predictable growth channel.",
by: "Name, Role, Company",
by: "Sarah Lin, Head of Growth, Lumen Apparel",
},
{
quote:
"They talk in revenue, not impressions. First agency that moved our pipeline.",
by: "Name, Role, Company",
by: "Marcus Reyes, CMO, Northpeak SaaS",
},
] as const;

View file

@ -38,6 +38,9 @@
/* gradients */
--grad-brand: linear-gradient(100deg, var(--blue), var(--violet) 52%, var(--emerald));
/* darker gradient used ONLY behind the white primary-button label so the
label clears AA (>=5:1) on the blue end while keeping the gradient look */
--grad-btn: linear-gradient(100deg, #2563eb, #7c3aed 52%, #059669);
--grad-text: linear-gradient(100deg, #7cb2ff, #b69bff 50%, #4ee3ad);
/* darker brand gradient for legible gradient-text on the light "paper" bg */
--grad-text-ink: linear-gradient(100deg, #2563eb, #7c3aed 50%, #059669);
@ -270,7 +273,7 @@ body::before {
.btn--sm { padding: 0.6rem 1.1rem; font-size: var(--step--1); }
.btn--accent {
color: #fff;
background: var(--grad-brand);
background: var(--grad-btn);
background-size: 200% 200%;
background-position: 0% 50%;
box-shadow: 0 8px 30px -8px rgba(139, 92, 246, 0.55);
@ -815,20 +818,29 @@ body::before {
.proof {
position: relative;
z-index: 2;
padding-block: clamp(3rem, 6vw, 5rem);
/* ~30% less vertical padding so the section reads tighter, not sparse */
padding-block: clamp(2.1rem, 4.2vw, 3.5rem);
border-bottom: 1px solid var(--c-line);
}
.proof__label { display: block; margin-bottom: 1.6rem; }
.proof__list { display: flex; flex-wrap: wrap; gap: 0.7rem 0.9rem; }
/* tighter gap + wrap so EVERY pill (incl. "Marketplaces") shows at full
opacity and nothing is clipped at the right edge */
.proof__list {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 0.7rem;
max-width: 100%;
}
.proof__item {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 1.1rem;
padding: 0.55rem 1rem;
border: 1px solid var(--c-line-strong);
border-radius: 999px;
font-family: var(--font-mono);
font-size: var(--step--1);
white-space: nowrap;
color: var(--c-text-dim);
transition:
color var(--t-mid) var(--ease-out),
@ -855,6 +867,28 @@ body::before {
}
}
/* second row — monochrome partner / certification marks */
.proof__logos {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: clamp(1.4rem, 4vw, 3rem);
margin-top: clamp(1.4rem, 3vw, 2.2rem);
padding-top: clamp(1.2rem, 3vw, 1.8rem);
border-top: 1px solid var(--c-line);
}
.proof__logo { display: inline-flex; }
.proof__logo-svg {
display: inline-flex;
color: #c2c2cf; /* monochrome, ~9:1 on dark */
opacity: 0.85;
transition: color var(--t-mid) var(--ease-out), opacity var(--t-mid) var(--ease-out);
}
.proof__logo-svg svg { height: 24px; width: auto; }
@media (hover: hover) and (pointer: fine) {
.proof__logo:hover .proof__logo-svg { color: var(--c-text); opacity: 1; }
}
/* ---------------------------------------------------------------------------
12. SERVICES LEDGER
--------------------------------------------------------------------------- */
@ -934,6 +968,9 @@ body::before {
/* ---------------------------------------------------------------------------
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__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@ -955,6 +992,14 @@ body::before {
margin-inline: auto;
}
.score__chart { margin-top: clamp(2.5rem, 5vw, 4rem); max-width: 600px; margin-inline: auto; }
.score__foot {
margin-top: clamp(1.4rem, 3vw, 2.2rem);
text-align: center;
font-family: var(--font-mono);
font-size: 15px;
letter-spacing: 0.02em;
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;
@ -1075,25 +1120,211 @@ body::before {
.work__cta { margin-top: clamp(2.5rem, 5vw, 4rem); text-align: center; }
/* ---------------------------------------------------------------------------
15. PROCESS LOOP (pinned + morph)
15. PROCESS LOOP (pinned + morph + per-step visual panels)
--------------------------------------------------------------------------- */
.loop {
/* active-step accent, set by JS as the scroll advances */
--loop-accent: #3b82f6;
}
.loop-pin { min-height: 100svh; display: flex; align-items: center; }
.loop__inner { width: 100%; }
.loop__stage {
display: grid;
grid-template-columns: 0.9fr 1.1fr;
gap: clamp(2rem, 5vw, 5rem);
grid-template-columns: 1fr 1fr;
gap: clamp(2rem, 5vw, 4.5rem);
align-items: center;
margin-top: clamp(2rem, 4vw, 3rem);
}
.loop-glyph {
aspect-ratio: 1;
width: min(100%, 360px);
margin-inline: auto;
display: grid; place-items: center;
background: radial-gradient(circle, rgba(139, 92, 246, 0.1), transparent 70%);
/* dark "stage card" floats the glyph + per-step visual over the cream paper,
mirroring how the work-cards float over the dark canvas. Kills the void. */
.loop-card {
position: relative;
aspect-ratio: 4 / 3;
width: 100%;
border-radius: var(--radius);
background: linear-gradient(180deg, #0c0c12, #08080d);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 40px 90px -40px rgba(20, 20, 26, 0.45),
0 2px 0 rgba(255, 255, 255, 0.04) inset;
overflow: hidden;
display: grid;
place-items: center;
padding: clamp(1.4rem, 3vw, 2.4rem);
}
.loop-glyph svg { width: 70%; filter: drop-shadow(0 10px 30px rgba(139, 92, 246, 0.4)); }
/* soft gradient glow inside the card so it reads premium, not a flat black box */
.loop-card__glow {
position: absolute;
inset: -30% -10% auto -10%;
height: 90%;
background: radial-gradient(
60% 70% at 50% 0%,
color-mix(in srgb, var(--loop-accent) 30%, transparent),
transparent 70%
);
filter: blur(10px);
opacity: 0.7;
transition: background var(--t-slow) var(--ease-out);
pointer-events: none;
}
.loop-glyph {
position: absolute;
top: clamp(1rem, 3vw, 1.8rem);
left: 50%;
transform: translateX(-50%);
aspect-ratio: 1;
width: min(34%, 130px);
display: grid; place-items: center;
}
.loop-glyph svg { width: 100%; filter: drop-shadow(0 10px 28px rgba(139, 92, 246, 0.45)); }
/* per-step visual panels — stacked; active one fades/lifts in */
.loop-panels {
position: absolute;
inset: auto clamp(1.2rem, 3vw, 2rem) clamp(1.2rem, 3vw, 2rem);
display: grid;
}
.loop-panel {
grid-area: 1 / 1;
width: 100%;
opacity: 0;
transform: translateY(10px);
filter: blur(3px);
transition:
opacity 0.5s var(--ease-out),
transform 0.5s var(--ease-out),
filter 0.5s var(--ease-out);
pointer-events: none;
}
.loop-panel.is-active {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.loop-panel__cap {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: var(--step--1);
text-transform: uppercase;
letter-spacing: 0.12em;
color: #c8c8d6; /* ~9:1 on #0c0c12 */
margin-bottom: 0.8rem;
}
.loop-panel__dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--loop-accent);
box-shadow: 0 0 10px var(--loop-accent);
}
.loop-panel__note {
margin-top: 0.7rem;
font-size: var(--step--1);
color: #aeaebd; /* ~6:1 on #0c0c12 */
}
.loop-panel__lab {
font-weight: 800;
color: #f4f4f7;
}
/* Audit panel — leak chart */
.loop-panel__svg {
width: 100%;
height: clamp(70px, 12vw, 110px);
}
/* Plan panel — channel x objective matrix */
.loop-matrix {
display: grid;
grid-template-columns: 4.5rem repeat(3, 1fr);
gap: 6px;
align-items: center;
}
.loop-matrix__h,
.loop-matrix__r {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.04em;
color: #c8c8d6;
}
.loop-matrix__h { text-align: center; }
.loop-matrix__r { text-align: left; }
.loop-matrix__c {
height: clamp(20px, 3.2vw, 30px);
border-radius: 6px;
background: color-mix(in srgb, var(--loop-accent) 18%, transparent);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.loop-matrix__c[data-lvl="2"] { background: color-mix(in srgb, var(--loop-accent) 42%, transparent); }
.loop-matrix__c[data-lvl="3"] {
background: color-mix(in srgb, var(--loop-accent) 78%, transparent);
border-color: transparent;
}
/* Execute panel — rising bars (animate up when this panel becomes active) */
.loop-bars {
display: flex;
align-items: flex-end;
gap: clamp(6px, 1.5vw, 12px);
height: clamp(70px, 12vw, 110px);
}
.loop-bar {
flex: 1;
height: var(--h);
border-radius: 5px 5px 0 0;
background: linear-gradient(
var(--loop-accent),
color-mix(in srgb, var(--loop-accent) 25%, transparent)
);
transform: scaleY(0.18);
transform-origin: bottom;
}
.loop-panel.is-active .loop-bar {
animation: loopBarGrow 0.7s var(--ease-out) forwards;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes loopBarGrow {
0% { transform: scaleY(0.18); }
72% { transform: scaleY(1.04); }
100% { transform: scaleY(1); }
}
/* Report panel — metric card */
.loop-metric {
display: grid;
gap: 0.3rem;
}
.loop-metric__num {
font-size: var(--step-4);
line-height: 1;
color: transparent;
background: var(--grad-text); /* light gradient — legible on dark card */
-webkit-background-clip: text;
background-clip: text;
}
.loop-metric__lab {
font-size: var(--step--1);
color: #aeaebd;
}
.loop-metric__trend {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.6rem;
}
.loop-metric__trend svg { width: 70px; height: 22px; }
.loop-metric__trend em {
font-style: normal;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--emerald);
}
.loop__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5rem; }
.loop__count {
display: inline-flex;
@ -1120,24 +1351,45 @@ body::before {
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.18), 0 0 14px rgba(124, 58, 237, 0.5);
}
.loop__steps ol { display: flex; flex-direction: column; gap: clamp(1.2rem, 2.5vw, 2rem); }
/* Inactive steps must stay LEGIBLE (was opacity 0.32 + blur => ~1.5:1).
Now we dim via explicit accessible colors and a tiny opacity, no blur. */
.loop-step {
display: flex;
gap: 1.2rem;
opacity: 0.32;
filter: blur(1.5px);
opacity: 0.92;
transition:
opacity 0.45s var(--ease-out),
transform 0.45s var(--ease-out),
filter 0.45s var(--ease-out);
transform 0.45s var(--ease-out);
}
.loop-step.is-active { opacity: 1; transform: translateX(8px); }
.loop-step__n {
font-family: var(--font-mono);
font-weight: 600;
font-size: var(--step-0);
color: #6b675f; /* ~4.9:1 on paper */
}
.loop-step.is-active { opacity: 1; transform: translateX(8px); filter: blur(0); }
.loop-step__n { font-family: var(--font-mono); font-weight: 600; font-size: var(--step-0); color: var(--paper-dim); }
.loop-step.is-active .loop-step__n { color: var(--violet-600); }
.loop-step__name { font-size: var(--step-2); font-weight: 900; letter-spacing: -0.02em; }
.loop-step__desc { margin-top: 0.3rem; color: var(--paper-dim); font-size: var(--step-0); max-width: 42ch; }
.loop-step__name {
font-size: var(--step-2);
font-weight: 900;
letter-spacing: -0.02em;
color: #6b675f; /* inactive title — ~4.9:1 on paper */
transition: color 0.45s var(--ease-out);
}
.loop-step.is-active .loop-step__name { color: #14141a; } /* active title */
.loop-step__desc {
margin-top: 0.3rem;
/* inactive description requested ~#8a857b, nudged to #726e63 so normal-size
body text clears WCAG AA (4.5:1) on the cream paper, not just large text */
color: #726e63;
font-size: var(--step-0);
max-width: 42ch;
transition: color 0.45s var(--ease-out);
}
.loop-step.is-active .loop-step__desc { color: var(--paper-dim); } /* active desc */
@media (max-width: 860px) {
.loop__stage { grid-template-columns: 1fr; }
.loop-glyph { width: min(60%, 240px); }
.loop-card { aspect-ratio: 5 / 4; max-width: 480px; margin-inline: auto; }
}
/* ---------------------------------------------------------------------------
@ -1300,6 +1552,14 @@ body::before {
.kicker__dot { animation: none !important; }
/* process steps are a plain legible list when motion is off */
.loop-step { opacity: 1 !important; filter: none !important; transform: none !important; }
.loop-step__name { color: #14141a !important; }
.loop-step__desc { color: var(--paper-dim) !important; }
.loop-step__n { color: var(--violet-600) !important; }
.loop-rail__fill { transform: scaleY(1) !important; }
/* no JS to swap panels: show the first (Audit) visual statically so the
stage card is never an empty box; the legible steps list carries the rest */
.loop-panel { opacity: 0; transform: none !important; filter: none !important; }
.loop-panel.is-active { opacity: 1 !important; }
.loop-bar { transform: scaleY(1) !important; animation: none !important; }
.divider__line { stroke-dasharray: none !important; }
}

View file

@ -38,6 +38,73 @@ const INDUSTRIES = [
"Marketplaces",
];
/* Monochrome partner / certification marks, recreated as simple inline SVG
wordmark-badges (no raster, no copied brand art). Illustrative, consistent
with the footer "illustrative samples" disclaimer. ~24px tall, --c-text-dim. */
const TRUST_MARKS: { label: string; svg: React.ReactNode }[] = [
{
label: "Google Partner",
svg: (
<svg viewBox="0 0 150 24" role="img" focusable="false">
<circle cx="11" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="2.2" />
<path d="M11 12h9" stroke="currentColor" strokeWidth="2.2" />
<path d="M16 12a5 5 0 1 0-1.5 3.6" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
<text x="28" y="16" fontFamily="var(--font-sans)" fontSize="13" fontWeight="700" fill="currentColor">
Google Partner
</text>
</svg>
),
},
{
label: "Meta Business Partner",
svg: (
<svg viewBox="0 0 178 24" role="img" focusable="false">
<path
d="M4 16c0-5 2.5-8 5.5-8 2.6 0 4 2 5.5 5 1.5-3 2.9-5 5.5-5 3 0 5.5 3 5.5 8M4 16c0 2 1 3 2.6 3 2.2 0 3.6-2.8 4.9-5M22 16c0 2-1 3-2.6 3-2.2 0-3.6-2.8-4.9-5"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
<text x="32" y="16" fontFamily="var(--font-sans)" fontSize="13" fontWeight="700" fill="currentColor">
Meta Business Partner
</text>
</svg>
),
},
{
label: "HubSpot Certified",
svg: (
<svg viewBox="0 0 158 24" role="img" focusable="false">
<circle cx="12" cy="14" r="6" fill="none" stroke="currentColor" strokeWidth="2.2" />
<circle cx="18.5" cy="6.5" r="2.6" fill="none" stroke="currentColor" strokeWidth="2" />
<path d="M12 8V5M15 12l2.2-3.4" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
<text x="30" y="16" fontFamily="var(--font-sans)" fontSize="13" fontWeight="700" fill="currentColor">
HubSpot Certified
</text>
</svg>
),
},
{
label: "Shopify Plus Partner",
svg: (
<svg viewBox="0 0 168 24" role="img" focusable="false">
<path
d="M9 4 5 5 3 20l8 1 2-16-4-1Zm0 0 1.6.4M11 6.5c-1.6 0-2.2 1.2-2.2 2.2 0 1.4 2.4 1.3 2.4 2.8 0 .7-.6 1-1.2 1"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
strokeLinecap="round"
/>
<text x="22" y="16" fontFamily="var(--font-sans)" fontSize="13" fontWeight="700" fill="currentColor">
Shopify Plus Partner
</text>
</svg>
),
},
];
/* Small live-feeling figures that flank the manifesto so the section reads full,
not empty. Illustrative samples, consistent with the rest of the prototype. */
const MANIFESTO_STATS = [
@ -105,6 +172,19 @@ export default function Page() {
</RevealItem>
))}
</Reveal>
{/* second row — monochrome partner / certification marks (inline
SVG, no raster) so the section reads substantiated, not sparse */}
<Reveal as="ul" className="proof__logos" stagger={0.06} delay={120}>
{TRUST_MARKS.map((m) => (
<RevealItem as="li" className="proof__logo" key={m.label}>
<span className="proof__logo-svg" aria-hidden="true">
{m.svg}
</span>
<span className="sr-only">{m.label}</span>
</RevealItem>
))}
</Reveal>
</div>
</section>

BIN
audit/d-sec-01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

BIN
audit/d-sec-02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

BIN
audit/d-sec-03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

BIN
audit/d-sec-04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

BIN
audit/d-sec-05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
audit/d-sec-06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

BIN
audit/d-sec-07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
audit/d-sec-08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

BIN
audit/d-sec-09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

BIN
audit/d-sec-10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

BIN
audit/d-sec-11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

BIN
audit/d-sec-12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
audit/d-sec-13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
audit/d-sec-14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

BIN
audit/d-sec-15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

BIN
audit/desktop-fold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

BIN
audit/desktop-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

24
audit/headings.txt Normal file
View file

@ -0,0 +1,24 @@
H1: Marketing that grows your revenue.
H2: How we grow your business
H3: SEO that compounds
H3: Paid ads that pay back
H3: Content that converts
H3: Social that builds demand
H3: Web & landing pages
H3: Creative & brand
H2: Numbers we report on
H2: Proof, not promises
H2: How it works
H3: Audit
H3: Plan
H3: Execute
H3: Report
H2: The number is the point
H2: Questions about working with a digital marketing agency
H3: What does a digital marketing agency do?
H3: How much does a digital marketing agency cost?
H3: How long until I see results?
H3: What makes Feedback Studios different?
H3: Which businesses do you work with?
H3: What should I look for in a marketing agency?
H2: Ready to grow?

BIN
audit/m-sec-01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
audit/m-sec-02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

BIN
audit/m-sec-03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

BIN
audit/m-sec-04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

BIN
audit/m-sec-05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

BIN
audit/m-sec-06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

BIN
audit/m-sec-07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

BIN
audit/m-sec-08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
audit/m-sec-09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
audit/m-sec-10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
audit/m-sec-11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
audit/m-sec-12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
audit/m-sec-13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

BIN
audit/m-sec-14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

BIN
audit/m-sec-15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

BIN
audit/m-sec-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

BIN
audit/mobile-fold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
audit/mobile-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

20
audit/outline.txt Normal file
View file

@ -0,0 +1,20 @@
HEADER .site-head | h=85 | Feedback Studios Services Work About FAQ Get a growth audit
SECTION .hero | h=900 | RESULTS-DRIVEN DIGITAL MARKETING AGENCY Marketing that grows your revenue. We run paid, SE
SECTION .tape | h=133 | Revenue, not vanity metrics ✱ Paid ✱ SEO ✱ Content ✱ Social ✱ +52% ROAS ✱ +217% demos ✱ +1
SECTION .manifesto | h=951 | THE PROBLEM WITH MOST AGENCIES Most budgets buy activity, not outcomes. Dashboards fill wi
SECTION .proof | h=255 | TRUSTED BY TEAMS THAT CARE ABOUT THE SALES NUMBER E-commerce B2B SaaS Clinics Professional
SECTION .services | h=1165 | SERVICES / 06 How we grow your business 01 SEO that compounds Rank for the searches your b
HEADER .sec-head | h=114 | SERVICES / 06 How we grow your business
SECTION .score | h=789 | THE SCOREBOARD — SAMPLE DATA Numbers we report on client revenue generated $40M+ client re
HEADER .sec-head | h=128 | THE SCOREBOARD — SAMPLE DATA Numbers we report on
SECTION .work | h=1077 | SELECTED WORK — SAMPLE Proof, not promises 01 E-commerce · Fashion BEFORE Rising ad costs
HEADER .sec-head | h=114 | SELECTED WORK — SAMPLE Proof, not promises
SECTION .loop | h=4579 | THE FEEDBACK LOOP How it works 04 / 04 01 Audit We find where your revenue is leaking. 02
HEADER .sec-head | h=128 | THE FEEDBACK LOOP How it works 04 / 04
SECTION .quotes | h=810 | IN THEIR WORDS — SAMPLE The number is the point “ We were spending $20k a month on ads wit
HEADER .sec-head | h=114 | IN THEIR WORDS — SAMPLE The number is the point
SECTION .faq-sec | h=1254 | FAQ Questions about working with a digital marketing agency What does a digital marketing
HEADER .sec-head | h=280 | FAQ Questions about working with a digital marketing agency
SECTION .final | h=737 | THE BOTTOM LINE Ready to grow? No long contracts. No vanity reports. Marketing you can mea
HEADER . | h=280 | Ready to grow?
FOOTER .colophon | h=540 | Feedback Studios. Marketing that grows your revenue. Services Work About FAQ Contact Linke

1
audit/page.html Normal file

File diff suppressed because one or more lines are too long

72
capture.js Normal file
View file

@ -0,0 +1,72 @@
const { chromium } = require('playwright');
const fs = require('fs');
const URL = 'https://studiosfeedback.com';
const OUT = '/home/claude/agency-web/audit';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const VIEWPORTS = [
['desktop', 1440, 900],
['mobile', 390, 844],
];
async function scrollPass(page) {
await page.evaluate(async () => {
await new Promise((resolve) => {
let y = 0;
const step = () => {
window.scrollTo(0, y);
y += 500;
if (y < document.body.scrollHeight) {
setTimeout(step, 130);
} else {
window.scrollTo(0, 0);
setTimeout(resolve, 400);
}
};
step();
});
});
}
(async () => {
const browser = await chromium.launch();
for (const [name, w, h] of VIEWPORTS) {
const ctx = await browser.newContext({
viewport: { width: w, height: h },
userAgent: UA,
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
await page.goto(URL, { waitUntil: 'networkidle', timeout: 60000 }).catch(async () => {
await page.goto(URL, { waitUntil: 'load', timeout: 60000 });
});
// dismiss consent / popups
for (const sel of [
'text=/^(accept|aceptar|agree|ok|entendido|got it)/i',
"[id*='cookie' i] button",
"[class*='consent' i] button",
]) {
try { await page.locator(sel).first().click({ timeout: 700 }); } catch (e) {}
}
await page.keyboard.press('Escape').catch(() => {});
await scrollPass(page);
await page.waitForTimeout(1200);
// above the fold
await page.screenshot({ path: `${OUT}/${name}-fold.png` });
// full page
await page.screenshot({ path: `${OUT}/${name}-full.png`, fullPage: true });
if (name === 'desktop') {
fs.writeFileSync(`${OUT}/page.html`, await page.content(), 'utf-8');
const bh = await page.evaluate(() => document.body.scrollHeight);
console.log('desktop body scrollHeight =', bh);
}
console.log(`captured ${name}`);
await ctx.close();
}
await browser.close();
})();

67
crop.js Normal file
View file

@ -0,0 +1,67 @@
const { chromium } = require('playwright');
const URL = 'https://studiosfeedback.com';
const OUT = '/home/claude/agency-web/audit';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
async function scrollPass(page) {
await page.evaluate(async () => {
await new Promise((resolve) => {
let y = 0;
const step = () => {
window.scrollTo(0, y);
y += 500;
if (y < document.body.scrollHeight) setTimeout(step, 120);
else { window.scrollTo(0, 0); setTimeout(resolve, 400); }
};
step();
});
});
}
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
userAgent: UA,
deviceScaleFactor: 1.5,
});
const page = await ctx.newPage();
await page.goto(URL, { waitUntil: 'networkidle', timeout: 60000 }).catch(() => {});
await scrollPass(page);
await page.waitForTimeout(800);
const total = await page.evaluate(() => document.body.scrollHeight);
const vh = 900;
let idx = 0;
for (let y = 0; y < total; y += vh) {
await page.evaluate((yy) => window.scrollTo(0, yy), y);
await page.waitForTimeout(500);
idx++;
const padded = String(idx).padStart(2, '0');
await page.screenshot({ path: `${OUT}/d-sec-${padded}.png` });
}
console.log('total sections:', idx, 'pageHeight:', total);
// Also dump structural outline for the report
const outline = await page.evaluate(() => {
const out = [];
document.querySelectorAll('section, header, footer, [class*=section i]').forEach((el) => {
const r = el.getBoundingClientRect();
const txt = (el.innerText || '').trim().replace(/\s+/g, ' ').slice(0, 90);
out.push(`${el.tagName} .${(el.className||'').toString().split(' ')[0]} | h=${Math.round(el.offsetHeight)} | ${txt}`);
});
return out.join('\n');
});
require('fs').writeFileSync(`${OUT}/outline.txt`, outline, 'utf-8');
// headings outline
const heads = await page.evaluate(() =>
[...document.querySelectorAll('h1,h2,h3,h4')]
.map((h) => `${h.tagName}: ${(h.innerText||'').trim().replace(/\s+/g,' ')}`)
.join('\n')
);
require('fs').writeFileSync(`${OUT}/headings.txt`, heads, 'utf-8');
await browser.close();
})();

19
mcrop.js Normal file
View file

@ -0,0 +1,19 @@
const { chromium } = require('playwright');
const URL = 'https://studiosfeedback.com';
const OUT = '/home/claude/agency-web/audit';
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, userAgent: UA, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
const page = await ctx.newPage();
await page.goto(URL, { waitUntil: 'networkidle', timeout: 60000 }).catch(()=>{});
await page.evaluate(async () => { await new Promise(r=>{let y=0;const s=()=>{window.scrollTo(0,y);y+=400;if(y<document.body.scrollHeight)setTimeout(s,110);else{window.scrollTo(0,0);setTimeout(r,400);}};s();}); });
await page.waitForTimeout(800);
const total = await page.evaluate(()=>document.body.scrollHeight);
console.log('mobile pageHeight:', total);
const vh=844; let i=0;
for (let y=0;y<total;y+=vh){ await page.evaluate(yy=>window.scrollTo(0,yy),y); await page.waitForTimeout(450); i++; await page.screenshot({path:`${OUT}/m-sec-${String(i).padStart(2,'0')}.png`}); }
console.log('mobile sections:', i);
await browser.close();
})();

46
package-lock.json generated
View file

@ -7,12 +7,14 @@
"": {
"name": "agency-web",
"version": "0.1.0",
"license": "ISC",
"dependencies": {
"gsap": "^3.12.5",
"lenis": "^1.1.14",
"motion": "^12.40.0",
"next": "^15.5.19",
"ogl": "^1.0.11",
"playwright": "^1.61.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"split-type": "^0.3.4"
@ -746,6 +748,20 @@
}
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
@ -906,6 +922,36 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View file

@ -16,6 +16,7 @@
"motion": "^12.40.0",
"next": "^15.5.19",
"ogl": "^1.0.11",
"playwright": "^1.61.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"split-type": "^0.3.4"
@ -25,5 +26,14 @@
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"typescript": "^5.7.3"
}
},
"description": "",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://pgalindez:bc014bb1bcf4db3e55a1096f15b9992f251bf918@git.studiosfeedback.com/feedback-studios/agency-web.git"
},
"keywords": [],
"author": "",
"license": "ISC"
}