feat(landing): honest high-end redesign — kill fake social proof, brand gradient identity, final CTA, mobile fixes
- removed fabricated fork counts/'verified' badges, 'our customers ship today' copy, pseudo client logo marks and stale v0.1 badge (zero-user product must not fake traction) - new proof-by-specificity band: verifiable links to /docs/oauth, /status, /security instead of testimonial cosplay - identity: indigo-to-cyan brand gradient, indigo-tinted elevated surfaces, mono section kickers, terminal-chrome hero rotator, gradient h-11 CTA - how-it-works as connected pipeline; new final CTA band (page no longer ends on FAQ); footer upgraded to 4-column product/resources/legal - lib/pricing.ts single tier source for pricing page + landing teaser; annual-billing FAQ claim softened to truth (no annual checkout exists) - hero video preload=metadata + IntersectionObserver play (2.6MB off the critical path); 44px touch targets; mobile menu: Guides link, CTA, scroll-lock, escape/outside-tap close Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
This commit is contained in:
@@ -174,9 +174,19 @@ export function HeroStepRotator() {
|
||||
y: glowDy,
|
||||
}}
|
||||
/>
|
||||
{/* Terminal chrome: traffic lights + filename tab. The tile IS
|
||||
the product's aesthetic (build logs, configs), so it should
|
||||
look like a real terminal window, not a generic card. */}
|
||||
<div className="relative flex items-center justify-between border-b border-[--color-border] px-4 py-2.5">
|
||||
<span className="mono text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
{current.label}
|
||||
<span className="flex items-center gap-3">
|
||||
<span aria-hidden className="flex gap-1.5">
|
||||
<span className="size-2.5 rounded-full bg-[#ff5f57]/80" />
|
||||
<span className="size-2.5 rounded-full bg-[#febc2e]/80" />
|
||||
<span className="size-2.5 rounded-full bg-[#28c840]/80" />
|
||||
</span>
|
||||
<span className="mono text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
{current.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mono text-[10.5px] tracking-[0.16em] text-[--color-accent]">
|
||||
{current.badge}
|
||||
@@ -189,23 +199,28 @@ export function HeroStepRotator() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Step indicator — accent dot is wider + glows so the active step
|
||||
reads at a glance. Buttons stay clickable so users can jump. */}
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label="Hero flow steps">
|
||||
{/* Step indicator — the visible dot stays small, but each button
|
||||
carries generous padding so the effective touch target is ≥44px.
|
||||
Plain buttons with aria-current (not a tablist — no arrow-key
|
||||
semantics to honour, they're simple jump buttons). */}
|
||||
<div className="flex items-center" aria-label="Hero flow steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s.badge}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === step}
|
||||
aria-current={i === step}
|
||||
aria-label={`Jump to ${s.badge}`}
|
||||
onClick={() => setStep(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
i === step
|
||||
? 'w-9 bg-[--color-accent] shadow-[0_0_10px_rgba(99,102,241,0.65)]'
|
||||
: 'w-1.5 bg-[--color-border-strong] hover:bg-[--color-fg-subtle]'
|
||||
}`}
|
||||
/>
|
||||
className="flex min-h-11 min-w-11 items-center justify-center"
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
i === step
|
||||
? 'w-9 bg-[--color-accent] shadow-[0_0_10px_rgba(99,102,241,0.65)]'
|
||||
: 'w-1.5 bg-[--color-border-strong] hover:bg-[--color-fg-subtle]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,9 @@ function formatTime(seconds: number): string {
|
||||
export function HeroVideo() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Set when the user explicitly pauses — the IntersectionObserver must not
|
||||
// restart a video the user chose to stop.
|
||||
const userPausedRef = useRef(false);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playFailed, setPlayFailed] = useState(false);
|
||||
@@ -69,10 +72,26 @@ export function HeroVideo() {
|
||||
v.addEventListener('timeupdate', onTime);
|
||||
v.addEventListener('loadedmetadata', onMeta);
|
||||
v.addEventListener('durationchange', onMeta);
|
||||
// Best-effort autoplay attempt — silently fail on browsers that
|
||||
// block it; the overlay is the user's escape hatch.
|
||||
v.play().catch(() => undefined);
|
||||
// Play only while scrolled into view: with preload="metadata" the 2.6 MB
|
||||
// mp4 stays off the critical path until the section is actually visible,
|
||||
// and playback pauses again off-screen (saves battery + decode). The
|
||||
// .play() attempt is still best-effort — browsers that block autoplay
|
||||
// leave the overlay as the user's escape hatch. `userPaused` tracks an
|
||||
// explicit pause so the observer doesn't fight the user's choice.
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry) return;
|
||||
if (entry.isIntersecting) {
|
||||
if (!userPausedRef.current && v.paused) v.play().catch(() => undefined);
|
||||
} else if (!v.paused) {
|
||||
v.pause();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.35 },
|
||||
);
|
||||
io.observe(v);
|
||||
return () => {
|
||||
io.disconnect();
|
||||
v.removeEventListener('play', onPlay);
|
||||
v.removeEventListener('pause', onPause);
|
||||
v.removeEventListener('timeupdate', onTime);
|
||||
@@ -82,9 +101,12 @@ export function HeroVideo() {
|
||||
}, []);
|
||||
|
||||
// Clear any pending hide timer on unmount.
|
||||
useEffect(() => () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
@@ -106,6 +128,7 @@ export function HeroVideo() {
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
setPlayFailed(false);
|
||||
userPausedRef.current = false;
|
||||
try {
|
||||
// .load() resets the media element's resource selection — required
|
||||
// when an earlier autoplay attempt was blocked before the source
|
||||
@@ -116,6 +139,7 @@ export function HeroVideo() {
|
||||
setPlayFailed(true);
|
||||
}
|
||||
} else {
|
||||
userPausedRef.current = true;
|
||||
v.pause();
|
||||
}
|
||||
}, []);
|
||||
@@ -165,11 +189,10 @@ export function HeroVideo() {
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
poster="/videos/hero-poster.jpg"
|
||||
onClick={onVideoClick}
|
||||
className="size-full cursor-pointer object-cover"
|
||||
|
||||
@@ -2,27 +2,52 @@
|
||||
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const LINKS = [
|
||||
{ href: '/#how', label: 'How it works' },
|
||||
{ href: '/templates', label: 'Templates' },
|
||||
{ href: '/pricing', label: 'Pricing' },
|
||||
{ href: '/docs', label: 'Docs' },
|
||||
{ href: '/guides', label: 'Guides' },
|
||||
{ href: '/changelog', label: 'Changelog' },
|
||||
];
|
||||
|
||||
/** Hamburger menu shown below the md breakpoint, where the inline nav is hidden. */
|
||||
export function MarketingMobileMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// While the panel is open: lock body scroll, close on Escape and on any
|
||||
// pointerdown outside the menu subtree. All three effects unwind together
|
||||
// when the panel closes or the component unmounts.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<div ref={rootRef} className="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex size-8 items-center justify-center rounded-md text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||
className="flex size-11 items-center justify-center rounded-md text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||
>
|
||||
{open ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
@@ -34,20 +59,27 @@ export function MarketingMobileMenu() {
|
||||
// browser falls back to transparent. Inline `var()` is unambiguous.
|
||||
className="absolute inset-x-0 top-12 z-40 border-b border-[--color-border] shadow-lg shadow-black/40 backdrop-blur-md"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg) 80%, transparent)',
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg) 92%, transparent)',
|
||||
}}
|
||||
>
|
||||
<nav className="mx-auto flex max-w-6xl flex-col px-6">
|
||||
<nav className="mx-auto flex max-w-6xl flex-col px-6 pb-5">
|
||||
{LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="border-b border-[--color-border] py-3.5 text-[14px] text-[--color-fg-muted] transition-colors last:border-0 hover:text-[--color-fg]"
|
||||
className="flex min-h-11 items-center border-b border-[--color-border] text-[14px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href="/login"
|
||||
onClick={() => setOpen(false)}
|
||||
className="btn-brand mt-4 inline-flex h-11 w-full items-center justify-center rounded-md text-[14px] font-medium"
|
||||
>
|
||||
Start building free →
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user