feat(tuxedo): Field Almanac editorial composition
Deploy to route.crispygoat.com / deploy (push) Successful in 5m11s
Deploy to route.crispygoat.com / deploy (push) Successful in 5m11s
Layers on top of origin/main's existing editorial primitives. Replaces
the cinematic video hero with a typographic 'cover spread' and rebuilds
the page composition around numbered editorial sections.
Cover spread (TuxedoVideoHero):
- Newspaper-style masthead with location, altitude, weather, date
- Massive display headline with oversize 'XL' page numeral in margin
- Cover photo plate with crop marks, photo credit, plate number
- Floating marginalia (vertical text) drifting in on scroll
- Slow horizontal ticker with harvest facts at bottom
Page composition (page.tsx) — numbered editorial sections:
№ 00 Editor's Note two-column preamble, drop cap, pull quote
№ 01 The Almanac 6 specimen cards with hover-reveal notes
№ 02 The Cover Story photo + sticky stat marginalia
№ 03 The 2026 Tour typeset schedule with stat strip
№ 04 This Week's Lot inventory-style product cards w/ lot #s
№ 05 Chronicle of the Row 3-gen timeline on dark plate
№ 06 Letter from the Field letterpress subscribe card w/ deckle edge
Colophon back-of-book fine print
Adds editorial primitives to globals.css: section numbering, drop caps,
specimen tabs, pull quotes, crop marks, page numerals, ticker,
deckle-edge subscribe, almanac-paper backgrounds.
Removes dark fixed gradient from tuxedo/layout.tsx so per-section
.almanac-paper backgrounds win.
This commit is contained in:
@@ -1487,3 +1487,252 @@ select:-webkit-autofill:focus {
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
FIELD ALMANAC — Editorial primitives (Tuxedo Corn storefront)
|
||||
Layered on top of the existing editorial palette from origin/main.
|
||||
Distinct additions: section numbering, specimen tabs, drop caps,
|
||||
pull quotes, crop marks, page numerals, ticker, deckle subscribe.
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.almanac-paper {
|
||||
background:
|
||||
radial-gradient(ellipse 100% 60% at 50% 0%, rgba(180, 150, 100, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 80% 50% at 50% 100%, rgba(120, 90, 50, 0.05), transparent 60%),
|
||||
#f3ece0;
|
||||
color: #1a1814;
|
||||
position: relative;
|
||||
}
|
||||
.almanac-paper--bone { background: #ece4d4; }
|
||||
.almanac-paper--ink { background: #1a1814; color: #f3ece0; }
|
||||
|
||||
.almanac-grain::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
opacity: 0.12; mix-blend-mode: multiply;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0 0.04 0 0 0 0.6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||
z-index: 1;
|
||||
}
|
||||
.almanac-grain > * { position: relative; z-index: 2; }
|
||||
|
||||
.almanac-display {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variation-settings: "SOFT" 50, "WONK" 1, "opsz" 144;
|
||||
letter-spacing: -0.035em; line-height: 0.88; font-weight: 500;
|
||||
}
|
||||
.almanac-display-tight {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variation-settings: "SOFT" 30, "opsz" 60;
|
||||
letter-spacing: -0.018em; line-height: 1.02; font-weight: 450;
|
||||
}
|
||||
.almanac-body {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variation-settings: "opsz" 14; font-weight: 400;
|
||||
line-height: 1.55; letter-spacing: -0.005em;
|
||||
}
|
||||
.almanac-italic {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variation-settings: "opsz" 14; font-style: italic; font-weight: 400;
|
||||
}
|
||||
.almanac-smallcaps {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variant: all-small-caps; letter-spacing: 0.06em; font-weight: 500;
|
||||
}
|
||||
|
||||
.almanac-eyebrow {
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 10.5px; letter-spacing: 0.22em;
|
||||
text-transform: uppercase; font-weight: 400;
|
||||
}
|
||||
.almanac-dateline {
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 10.5px; letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.almanac-marginalia {
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 10.5px; letter-spacing: 0.08em; line-height: 1.5;
|
||||
}
|
||||
|
||||
.almanac-dropcap::first-letter {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variation-settings: "WONK" 1, "opsz" 144;
|
||||
float: left; font-size: 5.5em; line-height: 0.82;
|
||||
padding: 0.06em 0.08em 0 0; font-weight: 500; color: #1a1814;
|
||||
}
|
||||
|
||||
.almanac-rule { height: 1px; background: #1a1814; border: 0; width: 100%; }
|
||||
.almanac-rule--hair { height: 1px; background: rgba(26, 24, 20, 0.18); }
|
||||
.almanac-rule--double {
|
||||
height: 4px; border-top: 1px solid #1a1814;
|
||||
border-bottom: 1px solid #1a1814; background: transparent;
|
||||
}
|
||||
|
||||
.crop-marks {
|
||||
--c: #1a1814; position: absolute; inset: 0;
|
||||
pointer-events: none; z-index: 5;
|
||||
}
|
||||
.crop-marks::before,
|
||||
.crop-marks::after,
|
||||
.crop-marks > span::before,
|
||||
.crop-marks > span::after {
|
||||
content: ""; position: absolute; background: var(--c);
|
||||
}
|
||||
.crop-marks::before { top: -1px; left: 24px; width: 1px; height: 14px; }
|
||||
.crop-marks::after { top: -1px; right: 24px; width: 1px; height: 14px; }
|
||||
.crop-marks > span::before { bottom: -1px; left: 24px; width: 1px; height: 14px; }
|
||||
.crop-marks > span::after { bottom: -1px; right: 24px; width: 1px; height: 14px; }
|
||||
|
||||
.almanac-section-number {
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 11px; letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex; align-items: baseline; gap: 0.7em;
|
||||
}
|
||||
.almanac-section-number::before {
|
||||
content: ""; display: inline-block;
|
||||
width: 32px; height: 1px; background: currentColor;
|
||||
margin-right: 0.2em; vertical-align: middle;
|
||||
}
|
||||
|
||||
.almanac-pullquote {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variation-settings: "SOFT" 50, "WONK" 1, "opsz" 144;
|
||||
font-weight: 400; line-height: 1.02;
|
||||
letter-spacing: -0.025em; font-style: italic;
|
||||
}
|
||||
|
||||
.almanac-card {
|
||||
background: #fbf6ec; border: 1px solid rgba(26, 24, 20, 0.16);
|
||||
position: relative;
|
||||
transition: background 200ms cubic-bezier(0.32,0.72,0,1),
|
||||
transform 280ms cubic-bezier(0.32,0.72,0,1);
|
||||
}
|
||||
.almanac-card:hover { background: #fffaf0; transform: translateY(-2px); }
|
||||
|
||||
.almanac-tab {
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||
background: #1a1814; color: #f3ece0;
|
||||
padding: 4px 10px 4px 8px; display: inline-block; position: relative;
|
||||
}
|
||||
.almanac-tab::before {
|
||||
content: ""; position: absolute; top: 0; right: -6px;
|
||||
border-left: 6px solid #1a1814;
|
||||
border-top: 13px solid transparent;
|
||||
border-bottom: 13px solid transparent;
|
||||
}
|
||||
|
||||
@keyframes almanac-ticker {
|
||||
from { transform: translate3d(0,0,0); }
|
||||
to { transform: translate3d(-50%,0,0); }
|
||||
}
|
||||
.almanac-ticker {
|
||||
display: flex; width: max-content;
|
||||
animation: almanac-ticker 80s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.almanac-underline {
|
||||
background-image: linear-gradient(transparent calc(100% - 3px), #c89517 3px);
|
||||
background-size: 100% 100%; background-repeat: no-repeat;
|
||||
transition: background-size 320ms cubic-bezier(0.32,0.72,0,1);
|
||||
}
|
||||
.almanac-underline:hover {
|
||||
background-image: linear-gradient(transparent calc(100% - 6px), #c89517 6px);
|
||||
}
|
||||
|
||||
.almanac-btn-primary {
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase;
|
||||
font-weight: 500; padding: 16px 28px;
|
||||
background: #1a1814; color: #f3ece0;
|
||||
border: 1px solid #1a1814;
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
transition: background 220ms cubic-bezier(0.32,0.72,0,1),
|
||||
color 220ms cubic-bezier(0.32,0.72,0,1),
|
||||
transform 220ms cubic-bezier(0.32,0.72,0,1);
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.almanac-btn-primary:hover {
|
||||
background: #c89517; color: #1a1814;
|
||||
border-color: #c89517; transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.almanac-btn-ghost {
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase;
|
||||
font-weight: 500; padding: 16px 4px;
|
||||
color: #1a1814; display: inline-flex; align-items: center; gap: 12px;
|
||||
border-bottom: 1px solid #1a1814;
|
||||
transition: gap 220ms cubic-bezier(0.32,0.72,0,1);
|
||||
background: transparent; cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.almanac-btn-ghost:hover { gap: 18px; }
|
||||
|
||||
.almanac-vertical {
|
||||
writing-mode: vertical-rl; transform: rotate(180deg);
|
||||
font-family: var(--font-fragment-mono, "JetBrains Mono"), monospace;
|
||||
font-size: 10.5px; letter-spacing: 0.3em; text-transform: uppercase;
|
||||
}
|
||||
|
||||
.almanac-pagenum {
|
||||
font-family: var(--font-fraunces, "Georgia"), serif;
|
||||
font-variation-settings: "WONK" 1, "opsz" 144;
|
||||
font-weight: 500; font-size: 9vw; line-height: 0.78;
|
||||
letter-spacing: -0.04em;
|
||||
pointer-events: none; user-select: none;
|
||||
color: rgba(26, 24, 20, 0.08);
|
||||
}
|
||||
|
||||
.almanac-flourish {
|
||||
display: inline-block;
|
||||
transition: transform 380ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.almanac-flourish:hover { transform: rotate(-3deg) scale(1.05); }
|
||||
|
||||
.almanac-photo { position: relative; overflow: hidden; }
|
||||
.almanac-photo > img,
|
||||
.almanac-photo > .img { filter: contrast(1.04) saturate(0.92) sepia(0.08); }
|
||||
.almanac-photo::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.92' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.95 0 0 0 0 0.92 0 0 0 0 0.86 0 0 0 0.4 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||
mix-blend-mode: multiply; opacity: 0.35; pointer-events: none;
|
||||
}
|
||||
|
||||
/* Inverted (dark) section overrides */
|
||||
.almanac-paper--ink .almanac-card {
|
||||
background: rgba(243, 236, 224, 0.04);
|
||||
border-color: rgba(243, 236, 224, 0.18);
|
||||
}
|
||||
.almanac-paper--ink .almanac-card:hover { background: rgba(243, 236, 224, 0.08); }
|
||||
.almanac-paper--ink .almanac-rule { background: rgba(243, 236, 224, 0.6); }
|
||||
.almanac-paper--ink .almanac-rule--hair { background: rgba(243, 236, 224, 0.18); }
|
||||
.almanac-paper--ink .almanac-tab { background: #c89517; color: #1a1814; }
|
||||
.almanac-paper--ink .almanac-tab::before { border-left-color: #c89517; }
|
||||
.almanac-paper--ink .almanac-btn-primary {
|
||||
background: #f3ece0; color: #1a1814; border-color: #f3ece0;
|
||||
}
|
||||
.almanac-paper--ink .almanac-btn-primary:hover {
|
||||
background: #c89517; color: #1a1814; border-color: #c89517;
|
||||
}
|
||||
.almanac-paper--ink .almanac-btn-ghost {
|
||||
color: #f3ece0; border-color: rgba(243, 236, 224, 0.4);
|
||||
}
|
||||
.almanac-paper--ink .almanac-eyebrow,
|
||||
.almanac-paper--ink .almanac-dateline,
|
||||
.almanac-paper--ink .almanac-section-number,
|
||||
.almanac-paper--ink .almanac-marginalia {
|
||||
color: rgba(243, 236, 224, 0.55);
|
||||
}
|
||||
.almanac-paper--ink .almanac-pagenum { color: rgba(243, 236, 224, 0.07); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.almanac-ticker { animation: none; }
|
||||
.almanac-grain::after { opacity: 0.05; }
|
||||
}
|
||||
|
||||
.almanac-paper ::selection,
|
||||
.almanac-paper--ink ::selection {
|
||||
background: #c89517; color: #1a1814;
|
||||
}
|
||||
|
||||
@@ -103,14 +103,9 @@ export const viewport: Viewport = {
|
||||
export default function TuxedoLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Background gradient */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-emerald-950/50 via-zinc-950 to-zinc-950" />
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
{/* The storefront backdrop and ambient orbs are persistent. The
|
||||
page body itself crossfades between routes via the View
|
||||
Transitions API. */}
|
||||
{/* No fixed dark gradient — the Field Almanac composition sets its
|
||||
own per-section backgrounds (cream / bone / ink) via the
|
||||
.almanac-paper utility classes on each section. */}
|
||||
<div id="page-content" className="relative outline-none">
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
</div>
|
||||
|
||||
+946
-801
File diff suppressed because it is too large
Load Diff
@@ -1,724 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useReducer, useRef, useState } from "react";
|
||||
import { m as motion } from "framer-motion";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TUXEDO COVER — Field Almanac opening spread
|
||||
//
|
||||
// Replaces the cinematic video hero with a typographic editorial cover:
|
||||
// · Masthead with date / volume / coordinates / temperature strip
|
||||
// · Massive two-line display headline with oversize numerals in the margin
|
||||
// · Hand-set cover image with crop marks, photo credit, plate number
|
||||
// · Floating marginalia that drift in on scroll
|
||||
// · Three-axis GSAP entrance — opacity, Y translate, and a subtle letterpress
|
||||
// scale on the headline. No parallax on the cover photo (vestibular safe).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TuxedoVideoHeroProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
olatheSweetLogoUrl?: string | null;
|
||||
/** URL of a video (mp4/webm) to play muted & looped as the hero backdrop. */
|
||||
videoUrl?: string | null;
|
||||
/** Image shown as the video poster and as the static fallback if the
|
||||
* video fails to load, is blocked by autoplay policies, or the user
|
||||
* prefers reduced motion. Without this, the gradient stack stands on
|
||||
* its own. */
|
||||
posterUrl?: string | null;
|
||||
primaryButton?: string;
|
||||
secondaryButton?: string;
|
||||
onPrimaryClick?: () => void;
|
||||
onSecondaryClick?: () => void;
|
||||
};
|
||||
|
||||
const HERO_STATS = [
|
||||
{ stat: "40+", label: "Years Growing" },
|
||||
{ stat: "3", label: "Generations" },
|
||||
{ stat: "100%", label: "Hand-Picked" },
|
||||
] as const;
|
||||
const COVER_IMAGE =
|
||||
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-field-storm.jpg";
|
||||
|
||||
type State = {
|
||||
scrollProgress: number;
|
||||
};
|
||||
|
||||
type Action = { type: "SET_SCROLL_PROGRESS"; value: number };
|
||||
|
||||
const initialState: State = {
|
||||
scrollProgress: 0,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_SCROLL_PROGRESS":
|
||||
return { ...state, scrollProgress: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The big thesis panel for the storefront. Composes, in z-order:
|
||||
* - poster image (Next/Image, full-bleed, always present)
|
||||
* - optional muted looping video (renders only when its URL loads)
|
||||
* - gradient overlays (warm gold -> deep night)
|
||||
* - foreground editorial content (logo / eyebrow / title / copy / CTAs / stats)
|
||||
*
|
||||
* The hero is designed to feel complete as a STILL even without the video.
|
||||
* If `videoUrl` is missing, errors, or autoplay is blocked, the poster
|
||||
* image stays as the background and the page is not left with an empty
|
||||
* dark hole.
|
||||
*/
|
||||
export default function TuxedoVideoHero({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
olatheSweetLogoUrl,
|
||||
videoUrl,
|
||||
posterUrl,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
}: TuxedoVideoHeroProps) {
|
||||
const logoSrc = olatheSweetLogoUrl;
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const headlineRef = useRef<HTMLHeadingElement>(null);
|
||||
const marginaliaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
// `videoPlaying` reflects whether the <video> successfully produced frames.
|
||||
// It can flip false at any time: 404, codec error, autoplay blocked, or
|
||||
// prefers-reduced-motion. We tear the video element out of the DOM when
|
||||
// it's false so the poster image underneath becomes the visible backdrop.
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
|
||||
// ─── Lifecycle: gate autoplay off if the user prefers reduced motion. ────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
if (!mq || !mq.matches) return;
|
||||
// Pause + suppress the video entirely for reduced-motion users.
|
||||
const v = videoRef.current;
|
||||
if (v && !v.paused) v.pause();
|
||||
}, []);
|
||||
|
||||
// ─── GSAP SCROLL ANIMATIONS ───────────────────────────────────────────────
|
||||
// The motion here is intentionally restrained. Hero entrances are short
|
||||
// and small (no positional shift on scroll, no parallax scale on video).
|
||||
// Decorative orbs float on a slow 7s CSS keyframe (ambient, not jittery).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !sectionRef.current) return;
|
||||
let ctx: { revert: () => void } | null = null;
|
||||
(async () => {
|
||||
const { gsap } = await import("gsap");
|
||||
const { ScrollTrigger } = await import("gsap/ScrollTrigger");
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
ScrollTrigger.create({
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
end: "bottom top",
|
||||
scrub: true,
|
||||
onUpdate: (self) => {
|
||||
dispatch({ type: "SET_SCROLL_PROGRESS", value: self.progress });
|
||||
},
|
||||
});
|
||||
ctx = gsap.context(() => {
|
||||
if (headlineRef.current) {
|
||||
const words = headlineRef.current.querySelectorAll<HTMLElement>(".word");
|
||||
gsap.fromTo(
|
||||
words,
|
||||
{ opacity: 0, y: 24 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.9,
|
||||
stagger: 0.07,
|
||||
ease: "power3.out",
|
||||
delay: 0.15,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (contentRef.current) {
|
||||
gsap.to(contentRef.current, {
|
||||
opacity: 0,
|
||||
ease: "power1.out",
|
||||
if (marginaliaRef.current) {
|
||||
const items = marginaliaRef.current.querySelectorAll<HTMLElement>(".margin-note");
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0, x: -12 },
|
||||
{ opacity: 1, x: 0, duration: 0.7, stagger: 0.08, ease: "power2.out", delay: 0.9 }
|
||||
);
|
||||
}
|
||||
|
||||
gsap.to(".almanac-cover-img", {
|
||||
yPercent: 6,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
end: "40% top",
|
||||
scrub: 1.2,
|
||||
end: "bottom top",
|
||||
scrub: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const revealEls = gsap.utils.toArray<Element>(".hero-reveal");
|
||||
revealEls.forEach((el) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 8 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.32,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 95%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
gsap.to(".almanac-cover-fade", {
|
||||
opacity: 0.35,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
end: "60% top",
|
||||
scrub: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}, sectionRef);
|
||||
})();
|
||||
|
||||
gsap.to(".scroll-indicator", {
|
||||
opacity: 0,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "10% top",
|
||||
end: "30% top",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
}, sectionRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
return () => ctx?.revert();
|
||||
}, []);
|
||||
|
||||
const handlePrimaryClick = () => {
|
||||
const stopsSection = document.getElementById("stops");
|
||||
if (stopsSection) {
|
||||
stopsSection.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} else if (onPrimaryClick) {
|
||||
onPrimaryClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollProgressBar scrollProgress={state.scrollProgress} />
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="relative almanac-paper almanac-grain overflow-hidden"
|
||||
>
|
||||
<div className="crop-marks hidden md:block">
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="relative min-h-screen flex items-end overflow-hidden isolate bg-stone-950"
|
||||
>
|
||||
<HeroBackdrop
|
||||
videoUrl={videoUrl}
|
||||
posterUrl={posterUrl}
|
||||
videoRef={videoRef}
|
||||
onVideoStateChange={setVideoPlaying}
|
||||
/>
|
||||
<Masthead />
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-24 md:pb-32 pt-32"
|
||||
>
|
||||
<div className="max-w-3xl">
|
||||
<HeroContent
|
||||
logoSrc={logoSrc ?? null}
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
description={description}
|
||||
primaryButton={primaryButton}
|
||||
secondaryButton={secondaryButton}
|
||||
onPrimaryClick={handlePrimaryClick}
|
||||
onSecondaryClick={onSecondaryClick}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="almanac-cover-fade mx-auto max-w-[1480px] px-5 sm:px-8 md:px-14 pt-10 md:pt-16 pb-20 md:pb-32">
|
||||
{/* Top dateline strip */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3 mb-8 md:mb-14">
|
||||
<div className="almanac-eyebrow text-[#1a1814]/70">
|
||||
{eyebrow ?? "Olathe Sweet™ — Olathe, Colorado"}
|
||||
</div>
|
||||
<div className="almanac-mono text-[11px] tracking-[0.2em] uppercase text-[#1a1814]/55 hidden md:block">
|
||||
Vol. XL · Issue 03 · Harvest MMXXVI
|
||||
</div>
|
||||
<div className="almanac-mono text-[11px] tracking-[0.2em] uppercase text-[#1a1814]/55">
|
||||
38.598°N · 107.981°W
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="almanac-rule--hair mb-10 md:mb-16" />
|
||||
|
||||
{/* Two-column spread */}
|
||||
<div className="grid grid-cols-12 gap-6 md:gap-10 items-end">
|
||||
{/* LEFT — Editorial title */}
|
||||
<div className="col-span-12 lg:col-span-7 relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className="almanac-pagenum absolute -top-6 md:-top-12 -left-2 md:-left-6 select-none"
|
||||
>
|
||||
XL
|
||||
</div>
|
||||
|
||||
{olatheSweetLogoUrl ? (
|
||||
<div className="relative h-12 md:h-14 w-[260px] md:w-[320px] mb-6">
|
||||
<Image
|
||||
src={olatheSweetLogoUrl}
|
||||
alt="Olathe Sweet"
|
||||
fill
|
||||
sizes="(max-width: 768px) 80vw, 320px"
|
||||
style={{ objectFit: "contain", objectPosition: "left center" }}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h1
|
||||
ref={headlineRef}
|
||||
className="almanac-display text-[18vw] sm:text-[14vw] md:text-[10vw] lg:text-[8.5vw] xl:text-[150px] text-[#1a1814] mb-8 md:mb-10"
|
||||
style={{ wordSpacing: "0.05em" }}
|
||||
>
|
||||
{title.split(" ").map((word, i) => (
|
||||
<span key={`${i}-${word}`} className="word inline-block mr-[0.18em]">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
|
||||
<p className="almanac-body text-[#1a1814]/75 text-lg md:text-xl lg:text-2xl max-w-xl leading-[1.45] mb-10">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||
{primaryButton && (
|
||||
<button type="button" onClick={onPrimaryClick} className="almanac-btn-primary">
|
||||
<span>{primaryButton}</span>
|
||||
<span aria-hidden>→</span>
|
||||
</button>
|
||||
)}
|
||||
{secondaryButton && (
|
||||
<button type="button" onClick={onSecondaryClick} className="almanac-btn-ghost">
|
||||
<span>{secondaryButton}</span>
|
||||
<span aria-hidden>↗</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Cover photo */}
|
||||
<div className="col-span-12 lg:col-span-5 relative">
|
||||
<div ref={marginaliaRef} className="absolute inset-0 pointer-events-none z-10">
|
||||
<div className="margin-note absolute -top-3 -left-3 md:-top-4 md:-left-6 almanac-vertical text-[#1a1814]/50">
|
||||
Plate № 014 · zane
|
||||
</div>
|
||||
<div className="margin-note absolute top-1/2 -right-3 md:-right-6 almanac-vertical text-[#1a1814]/50">
|
||||
Pre-storm · 7:42 PM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure className="almanac-photo relative w-full aspect-[4/5] bg-[#1a1814] shadow-[0_30px_80px_-30px_rgba(26,24,20,0.5)]">
|
||||
<div className="almanac-cover-img absolute inset-[-6%] w-[112%] h-[112%]">
|
||||
<Image
|
||||
src={COVER_IMAGE}
|
||||
alt="Pre-storm corn rows receding to the mountains at dusk"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 90vw, 45vw"
|
||||
priority
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-4 almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#f3ece0]/85 z-10">
|
||||
Plate 014 / 048
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#f3ece0]/85 z-10">
|
||||
· 35mm · ƒ/2.8
|
||||
</div>
|
||||
|
||||
<figcaption className="absolute bottom-0 left-0 right-0 px-4 md:px-5 py-3 bg-[#1a1814]/80 backdrop-blur-[2px] z-10">
|
||||
<p className="almanac-italic text-[#f3ece0] text-sm md:text-[15px] leading-snug">
|
||||
Rows receding into the Uncompahgre, fifteen minutes before the rain.
|
||||
</p>
|
||||
<p className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#f3ece0]/55 mt-1">
|
||||
Photograph — Zane Harold · August 2026
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom rule + edition meta */}
|
||||
<div className="mt-14 md:mt-20 grid grid-cols-12 gap-6 items-end">
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
<div className="almanac-eyebrow text-[#1a1814]/55">A bulletin from</div>
|
||||
<div className="almanac-display-tight text-2xl md:text-3xl mt-2 text-[#1a1814]">
|
||||
Row 14, Block 3
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-8">
|
||||
<hr className="almanac-rule--hair" />
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-y-2 mt-3">
|
||||
<p className="almanac-italic text-[#1a1814]/70 text-base md:text-lg max-w-md">
|
||||
“The best sweet corn doesn’t travel. It shouldn’t have to.”
|
||||
</p>
|
||||
<p className="almanac-mono text-[10.5px] tracking-[0.2em] uppercase text-[#1a1814]/55">
|
||||
— Harold family · est. 1982
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollIndicator onClick={handlePrimaryClick} />
|
||||
<CornerDecorations videoPlaying={videoPlaying} />
|
||||
</section>
|
||||
|
||||
<HeroAnimations />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scroll Progress Bar ───────────────────────────────────────────────
|
||||
function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar pointer-events-none">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
|
||||
style={{
|
||||
transform: `scaleX(${scrollProgress})`,
|
||||
transformOrigin: "left",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Backdrop: poster + optional video + overlays ─────────────────────────
|
||||
function HeroBackdrop({
|
||||
videoUrl,
|
||||
posterUrl,
|
||||
videoRef,
|
||||
onVideoStateChange,
|
||||
}: {
|
||||
videoUrl?: string | null;
|
||||
posterUrl?: string | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
onVideoStateChange: (playing: boolean) => void;
|
||||
}) {
|
||||
// On video failure we set this true and let the poster image take over.
|
||||
// Suppresses DOM noise in the failure case (no broken <video> controls).
|
||||
return (
|
||||
<>
|
||||
{/* Poster as still fallback. Uses Next/Image so the LCP image is
|
||||
optimised by the framework; sits underneath everything with
|
||||
`object-cover: contain-ar-correct` style. Slow Ken Burns drift
|
||||
animates the poster when the video isn't covering it (initial
|
||||
2-3s of load, or if the video fails) — adds ambient motion to
|
||||
the first impression without competing with the video. */}
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
src={posterUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fill
|
||||
sizes="100vw"
|
||||
priority
|
||||
quality={80}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
zIndex: 0,
|
||||
}}
|
||||
className="select-none pointer-events-none animate-tvh-kenburns filter-editorial"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
Video: render unconditionally when a URL is provided, but mark the
|
||||
element as `data-failed` once any of the error events fire so we
|
||||
can hide it with CSS instead of pulling it from the DOM (keeps
|
||||
the network-friendly fetch behaviour). `autoPlay muted playsInline`
|
||||
are the three browser flags that together unblock iOS + Android
|
||||
autoplay; React renders them as proper boolean attributes.
|
||||
`preload="metadata"` keeps the first frame available without
|
||||
pulling the whole file on slow connections.
|
||||
*/}
|
||||
{videoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
disablePictureInPicture
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500 data-[failed=true]:opacity-0 data-[failed=true]:pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
poster={posterUrl ?? undefined}
|
||||
src={videoUrl}
|
||||
onCanPlay={() => onVideoStateChange(true)}
|
||||
onError={() => onVideoStateChange(false)}
|
||||
onStalled={() => {
|
||||
// If we never fire `canPlay`, the element stays hidden via
|
||||
// the `data-failed` data-attribute on the next paint.
|
||||
onVideoStateChange(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Tint layer — adds warmth without darkening the underlying field.
|
||||
`mix-blend-multiply` keeps the golden hour of the photograph. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 2,
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(255,193,7,0.10) 0%, rgba(255,213,79,0.05) 50%, transparent 100%)",
|
||||
mixBlendMode: "multiply",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Vignette — keeps eyes on the centered type. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 3,
|
||||
background:
|
||||
"radial-gradient(ellipse at 50% 60%, rgba(0,0,0,0) 0%, rgba(0,0,0,0.35) 60%, rgba(0,0,0,0.85) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Base darken — helps white text legibility in either regime. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 4,
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.05) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<FloatingOrbs />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Floating Orbs ───────────────────────────────────────────────────────
|
||||
function FloatingOrbs() {
|
||||
// Slowed from the original 950ms (which felt like a strobe) to 7s/8s
|
||||
// so the gold/green ambient lights drift like sunset on the field.
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-[5]" aria-hidden="true">
|
||||
<div
|
||||
className="absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20 animate-tvh-float-slow"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(232,163,23,0.45) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15 animate-tvh-float-delayed"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(63,110,58,0.45) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.085] mix-blend-overlay pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ──────
|
||||
type HeroContentProps = {
|
||||
logoSrc: string | null;
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: string;
|
||||
secondaryButton?: string;
|
||||
onPrimaryClick: () => void;
|
||||
onSecondaryClick?: () => void;
|
||||
};
|
||||
|
||||
function HeroContent({
|
||||
logoSrc,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
}: HeroContentProps) {
|
||||
return (
|
||||
<>
|
||||
<HeroLogo logoSrc={logoSrc} />
|
||||
|
||||
<div className="hero-reveal mt-6 flex items-center gap-4">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-300/90">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
{/* Altitude ribbon — runs alongside the existing eyebrow with a
|
||||
hairline divider, like a magazine masthead. Reinforces the
|
||||
geographic / altitude voice of the brand. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="hidden sm:block h-px flex-1 max-w-[64px] bg-amber-300/35"
|
||||
/>
|
||||
<p className="hidden sm:block text-[10px] font-bold uppercase tracking-[0.32em] text-amber-200/65">
|
||||
5,360 ft · Uncompahgre Valley
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 h-px w-16 bg-gradient-to-r from-amber-300/80 to-transparent" />
|
||||
|
||||
<HeroTitle title={title} />
|
||||
<HeroDescription description={description} />
|
||||
<Ticker />
|
||||
|
||||
<HeroCTAs
|
||||
primaryButton={primaryButton}
|
||||
secondaryButton={secondaryButton}
|
||||
onPrimaryClick={onPrimaryClick}
|
||||
onSecondaryClick={onSecondaryClick}
|
||||
/>
|
||||
|
||||
<HeroStats />
|
||||
</>
|
||||
<style jsx>{`
|
||||
.word {
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MASTHEAD — newspaper-style running header
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function Masthead() {
|
||||
return (
|
||||
<motion.div
|
||||
className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
|
||||
>
|
||||
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt="Olathe Sweet"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 80vw, 50vw"
|
||||
style={{ objectFit: "contain" }}
|
||||
className="drop-shadow-2xl"
|
||||
priority
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
<header className="relative z-20 border-b border-[#1a1814]/15 bg-[#f3ece0]/85 backdrop-blur-[2px]">
|
||||
<div className="mx-auto max-w-[1480px] px-5 sm:px-8 md:px-14">
|
||||
<div className="flex items-center justify-between py-3 md:py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
|
||||
Olathe, CO
|
||||
</span>
|
||||
<span className="text-[#1a1814]/25">·</span>
|
||||
<span className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
|
||||
5,360 ft
|
||||
</span>
|
||||
<span className="hidden md:inline text-[#1a1814]/25">·</span>
|
||||
<span className="hidden md:inline almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
|
||||
78°F · clear
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 -translate-x-1/2 almanac-display-tight text-base md:text-lg text-[#1a1814] tracking-[0.16em] uppercase">
|
||||
The Harold Almanac
|
||||
</div>
|
||||
|
||||
<div className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
|
||||
Sat · 8 Aug MMXXVI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroTitle({ title }: { title: string }) {
|
||||
// Subtle white/cool text shadow replaces the yellow tint (was reading
|
||||
// neon). Letter-spacing tightened on the largest sizes so the words
|
||||
// sit closer — feels like a printed farm-stand sign at highway scale.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TICKER — slow horizontal marquee of harvest facts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function Ticker() {
|
||||
const items = [
|
||||
"★ Sweet corn · Olathe Sweet™ parent line",
|
||||
"★ 90-day variety · planted late May",
|
||||
"★ Hand-picked between 4 and 9 a.m.",
|
||||
"★ Cooler-shelf within 90 minutes of pick",
|
||||
"★ Non-GMO · never hybridized",
|
||||
"★ Single-farm · 60 acres under pivot",
|
||||
"★ Grown at 5,360 ft · Uncompahgre Valley",
|
||||
"★ Family-owned since 1982",
|
||||
];
|
||||
const doubled = [...items, ...items];
|
||||
return (
|
||||
<h1
|
||||
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-[-0.025em] text-white leading-[0.95] mb-6"
|
||||
style={{
|
||||
textShadow:
|
||||
"0 4px 30px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.25)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroDescription({ description }: { description: string }) {
|
||||
return (
|
||||
<p
|
||||
className="hero-reveal text-xl md:text-2xl text-white/75 leading-relaxed max-w-xl mb-10"
|
||||
style={{ fontWeight: 300 }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCTAs({
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
}: {
|
||||
primaryButton?: string;
|
||||
secondaryButton?: string;
|
||||
onPrimaryClick: () => void;
|
||||
onSecondaryClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="hero-reveal flex flex-wrap items-center gap-5">
|
||||
{primaryButton && (
|
||||
<PrimaryButton label={primaryButton} onClick={onPrimaryClick} />
|
||||
)}
|
||||
{secondaryButton && (
|
||||
<SecondaryButton label={secondaryButton} onClick={onSecondaryClick} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #15803d 0%, #16a34a 100%)",
|
||||
boxShadow:
|
||||
"0 8px 32px rgba(22, 163, 74, 0.35), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
whileHover={{ y: -3 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 22 }}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-3 text-white">
|
||||
<span>{label}</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryButton({ label, onClick }: { label?: string; onClick?: () => void }) {
|
||||
if (!label) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex items-center gap-3 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950 rounded-full"
|
||||
style={{ color: "rgba(255,255,255,0.85)" }}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="uppercase tracking-widest text-[11px]">{label}</span>
|
||||
<span className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/40">
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroStats() {
|
||||
return (
|
||||
<div className="hero-reveal mt-12 md:mt-16 flex flex-wrap gap-x-10 gap-y-6 md:gap-x-14">
|
||||
{HERO_STATS.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div
|
||||
className="text-2xl md:text-3xl font-black text-white tabular-nums"
|
||||
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.4)" }}
|
||||
<div className="relative border-t border-b border-[#1a1814]/20 bg-[#1a1814] text-[#f3ece0] py-3 overflow-hidden">
|
||||
<div className="almanac-ticker">
|
||||
{doubled.map((t, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="almanac-mono text-[11px] tracking-[0.24em] uppercase px-6 shrink-0 text-[#f3ece0]/85 whitespace-nowrap"
|
||||
>
|
||||
{item.stat}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-[0.25em] mt-1"
|
||||
style={{ color: "rgba(255,255,255,0.55)" }}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scroll Indicator ─────────────────────────────────────────────────────
|
||||
function ScrollIndicator({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="scroll-indicator absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-2 text-white/55 hover:text-white/85 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 rounded-full"
|
||||
aria-label="Scroll to stops"
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span>
|
||||
<div className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5">
|
||||
<div className="w-1.5 h-3 rounded-full bg-white animate-tvh-scroll-dot" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Corner Decorations ───────────────────────────────────────────────────
|
||||
function CornerDecorations({ videoPlaying }: { videoPlaying: boolean }) {
|
||||
// The decorative SVG rings on the corners are interesting but distract
|
||||
// when the video is already playing. We tint them by the current
|
||||
// backdrop mode instead of always animating them.
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-8 right-8 opacity-25 hidden md:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none">
|
||||
<path
|
||||
d="M32 4C48 16, 56 32, 48 56C40 64, 24 64, 8 56C0 32, 16 16, 32 4"
|
||||
stroke="rgba(232,163,23,0.55)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M32 12C44 22, 48 36, 42 52C36 58, 26 58, 18 52C12 36, 20 22, 32 12"
|
||||
stroke="rgba(232,163,23,0.35)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-20 left-8 opacity-20 hidden md:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="20" stroke="rgba(63,110,58,0.55)" strokeWidth="1" />
|
||||
<circle cx="24" cy="24" r="14" stroke="rgba(63,110,58,0.4)" strokeWidth="1" />
|
||||
<circle cx="24" cy="24" r="8" stroke="rgba(63,110,58,0.25)" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Animations (global keyframes + reduced-motion overrides) ───────
|
||||
function HeroAnimations() {
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes tvh-float-slow {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(8px, -10px); }
|
||||
}
|
||||
|
||||
@keyframes tvh-float-delayed {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-10px, 8px); }
|
||||
}
|
||||
|
||||
@keyframes tvh-scroll-dot {
|
||||
0%, 100% { transform: translateY(0); opacity: 1; }
|
||||
50% { transform: translateY(8px); opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Slow Ken Burns on the hero poster. Animates while the video is
|
||||
still loading (first ~2s) or if the video fails entirely.
|
||||
Scaled subtly + tiny pan so it reads as ambient, not motion. */
|
||||
@keyframes tvh-kenburns {
|
||||
0% { transform: scale(1.02) translate(0, 0); }
|
||||
50% { transform: scale(1.07) translate(-0.6%, -0.4%); }
|
||||
100% { transform: scale(1.10) translate(-1.2%, -0.8%); }
|
||||
}
|
||||
.animate-tvh-kenburns {
|
||||
animation: tvh-kenburns 26s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.animate-tvh-float-slow {
|
||||
animation: tvh-float-slow 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-tvh-float-delayed {
|
||||
animation: tvh-float-delayed 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-tvh-scroll-dot {
|
||||
animation: tvh-scroll-dot 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
button, a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-tvh-float-slow,
|
||||
.animate-tvh-float-delayed,
|
||||
.animate-tvh-scroll-dot,
|
||||
.animate-tvh-kenburns,
|
||||
.hero-reveal {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.hero-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
/* Pause any looping media so the still poster stays put. */
|
||||
video {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user