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;
|
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 }) {
|
export default function TuxedoLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative">
|
<div className="min-h-screen relative">
|
||||||
{/* Background gradient */}
|
{/* No fixed dark gradient — the Field Almanac composition sets its
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-emerald-950/50 via-zinc-950 to-zinc-950" />
|
own per-section backgrounds (cream / bone / ink) via the
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
.almanac-paper utility classes on each section. */}
|
||||||
<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. */}
|
|
||||||
<div id="page-content" className="relative outline-none">
|
<div id="page-content" className="relative outline-none">
|
||||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+946
-801
File diff suppressed because it is too large
Load Diff
@@ -1,724 +1,332 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useReducer, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { m as motion } from "framer-motion";
|
|
||||||
import { gsap } from "gsap";
|
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
||||||
|
|
||||||
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 = {
|
type TuxedoVideoHeroProps = {
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
olatheSweetLogoUrl?: string | null;
|
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;
|
primaryButton?: string;
|
||||||
secondaryButton?: string;
|
secondaryButton?: string;
|
||||||
onPrimaryClick?: () => void;
|
onPrimaryClick?: () => void;
|
||||||
onSecondaryClick?: () => void;
|
onSecondaryClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HERO_STATS = [
|
const COVER_IMAGE =
|
||||||
{ stat: "40+", label: "Years Growing" },
|
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-field-storm.jpg";
|
||||||
{ stat: "3", label: "Generations" },
|
|
||||||
{ stat: "100%", label: "Hand-Picked" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
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({
|
export default function TuxedoVideoHero({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
olatheSweetLogoUrl,
|
olatheSweetLogoUrl,
|
||||||
videoUrl,
|
|
||||||
posterUrl,
|
|
||||||
primaryButton,
|
primaryButton,
|
||||||
secondaryButton,
|
secondaryButton,
|
||||||
onPrimaryClick,
|
onPrimaryClick,
|
||||||
onSecondaryClick,
|
onSecondaryClick,
|
||||||
}: TuxedoVideoHeroProps) {
|
}: TuxedoVideoHeroProps) {
|
||||||
const logoSrc = olatheSweetLogoUrl;
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const headlineRef = useRef<HTMLHeadingElement>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement>(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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
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 ───────────────────────────────────────────────
|
let ctx: { revert: () => void } | null = null;
|
||||||
// The motion here is intentionally restrained. Hero entrances are short
|
(async () => {
|
||||||
// and small (no positional shift on scroll, no parallax scale on video).
|
const { gsap } = await import("gsap");
|
||||||
// Decorative orbs float on a slow 7s CSS keyframe (ambient, not jittery).
|
const { ScrollTrigger } = await import("gsap/ScrollTrigger");
|
||||||
useEffect(() => {
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
if (typeof window === "undefined" || !sectionRef.current) return;
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
ctx = gsap.context(() => {
|
||||||
ScrollTrigger.create({
|
if (headlineRef.current) {
|
||||||
trigger: sectionRef.current,
|
const words = headlineRef.current.querySelectorAll<HTMLElement>(".word");
|
||||||
start: "top top",
|
gsap.fromTo(
|
||||||
end: "bottom top",
|
words,
|
||||||
scrub: true,
|
{ opacity: 0, y: 24 },
|
||||||
onUpdate: (self) => {
|
{
|
||||||
dispatch({ type: "SET_SCROLL_PROGRESS", value: self.progress });
|
opacity: 1,
|
||||||
},
|
y: 0,
|
||||||
});
|
duration: 0.9,
|
||||||
|
stagger: 0.07,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.15,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (contentRef.current) {
|
if (marginaliaRef.current) {
|
||||||
gsap.to(contentRef.current, {
|
const items = marginaliaRef.current.querySelectorAll<HTMLElement>(".margin-note");
|
||||||
opacity: 0,
|
gsap.fromTo(
|
||||||
ease: "power1.out",
|
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: {
|
scrollTrigger: {
|
||||||
trigger: sectionRef.current,
|
trigger: sectionRef.current,
|
||||||
start: "top top",
|
start: "top top",
|
||||||
end: "40% top",
|
end: "bottom top",
|
||||||
scrub: 1.2,
|
scrub: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const revealEls = gsap.utils.toArray<Element>(".hero-reveal");
|
gsap.to(".almanac-cover-fade", {
|
||||||
revealEls.forEach((el) => {
|
opacity: 0.35,
|
||||||
gsap.fromTo(
|
ease: "none",
|
||||||
el,
|
scrollTrigger: {
|
||||||
{ opacity: 0, y: 8 },
|
trigger: sectionRef.current,
|
||||||
{
|
start: "top top",
|
||||||
opacity: 1,
|
end: "60% top",
|
||||||
y: 0,
|
scrub: true,
|
||||||
duration: 0.32,
|
|
||||||
ease: "power1.out",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: el,
|
|
||||||
start: "top 95%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
});
|
}, sectionRef);
|
||||||
|
})();
|
||||||
|
|
||||||
gsap.to(".scroll-indicator", {
|
return () => ctx?.revert();
|
||||||
opacity: 0,
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: sectionRef.current,
|
|
||||||
start: "10% top",
|
|
||||||
end: "30% top",
|
|
||||||
scrub: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, sectionRef);
|
|
||||||
|
|
||||||
return () => ctx.revert();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePrimaryClick = () => {
|
|
||||||
const stopsSection = document.getElementById("stops");
|
|
||||||
if (stopsSection) {
|
|
||||||
stopsSection.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
} else if (onPrimaryClick) {
|
|
||||||
onPrimaryClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section
|
||||||
<ScrollProgressBar scrollProgress={state.scrollProgress} />
|
ref={sectionRef}
|
||||||
|
className="relative almanac-paper almanac-grain overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="crop-marks hidden md:block">
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
<section
|
<Masthead />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div className="relative">
|
||||||
ref={contentRef}
|
<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">
|
||||||
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-24 md:pb-32 pt-32"
|
{/* Top dateline strip */}
|
||||||
>
|
<div className="flex flex-wrap items-center justify-between gap-y-3 mb-8 md:mb-14">
|
||||||
<div className="max-w-3xl">
|
<div className="almanac-eyebrow text-[#1a1814]/70">
|
||||||
<HeroContent
|
{eyebrow ?? "Olathe Sweet™ — Olathe, Colorado"}
|
||||||
logoSrc={logoSrc ?? null}
|
</div>
|
||||||
eyebrow={eyebrow}
|
<div className="almanac-mono text-[11px] tracking-[0.2em] uppercase text-[#1a1814]/55 hidden md:block">
|
||||||
title={title}
|
Vol. XL · Issue 03 · Harvest MMXXVI
|
||||||
description={description}
|
</div>
|
||||||
primaryButton={primaryButton}
|
<div className="almanac-mono text-[11px] tracking-[0.2em] uppercase text-[#1a1814]/55">
|
||||||
secondaryButton={secondaryButton}
|
38.598°N · 107.981°W
|
||||||
onPrimaryClick={handlePrimaryClick}
|
</div>
|
||||||
onSecondaryClick={onSecondaryClick}
|
</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>
|
||||||
</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>
|
||||||
<div className="mt-3 h-px w-16 bg-gradient-to-r from-amber-300/80 to-transparent" />
|
|
||||||
|
|
||||||
<HeroTitle title={title} />
|
<Ticker />
|
||||||
<HeroDescription description={description} />
|
|
||||||
|
|
||||||
<HeroCTAs
|
<style jsx>{`
|
||||||
primaryButton={primaryButton}
|
.word {
|
||||||
secondaryButton={secondaryButton}
|
will-change: opacity, transform;
|
||||||
onPrimaryClick={onPrimaryClick}
|
}
|
||||||
onSecondaryClick={onSecondaryClick}
|
`}</style>
|
||||||
/>
|
</section>
|
||||||
|
|
||||||
<HeroStats />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MASTHEAD — newspaper-style running header
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
function Masthead() {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<header className="relative z-20 border-b border-[#1a1814]/15 bg-[#f3ece0]/85 backdrop-blur-[2px]">
|
||||||
className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
|
<div className="mx-auto max-w-[1480px] px-5 sm:px-8 md:px-14">
|
||||||
initial={{ opacity: 0 }}
|
<div className="flex items-center justify-between py-3 md:py-4">
|
||||||
animate={{ opacity: 1 }}
|
<div className="flex items-center gap-3">
|
||||||
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
|
<span className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
|
||||||
>
|
Olathe, CO
|
||||||
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
|
</span>
|
||||||
{logoSrc ? (
|
<span className="text-[#1a1814]/25">·</span>
|
||||||
<Image
|
<span className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
|
||||||
src={logoSrc}
|
5,360 ft
|
||||||
alt="Olathe Sweet"
|
</span>
|
||||||
fill
|
<span className="hidden md:inline text-[#1a1814]/25">·</span>
|
||||||
sizes="(max-width: 1024px) 80vw, 50vw"
|
<span className="hidden md:inline almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
|
||||||
style={{ objectFit: "contain" }}
|
78°F · clear
|
||||||
className="drop-shadow-2xl"
|
</span>
|
||||||
priority
|
</div>
|
||||||
/>
|
|
||||||
) : null}
|
<div className="absolute left-1/2 -translate-x-1/2 almanac-display-tight text-base md:text-lg text-[#1a1814] tracking-[0.16em] uppercase">
|
||||||
</motion.div>
|
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
|
// TICKER — slow horizontal marquee of harvest facts
|
||||||
// neon). Letter-spacing tightened on the largest sizes so the words
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// sit closer — feels like a printed farm-stand sign at highway scale.
|
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 (
|
return (
|
||||||
<h1
|
<div className="relative border-t border-b border-[#1a1814]/20 bg-[#1a1814] text-[#f3ece0] py-3 overflow-hidden">
|
||||||
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-[-0.025em] text-white leading-[0.95] mb-6"
|
<div className="almanac-ticker">
|
||||||
style={{
|
{doubled.map((t, i) => (
|
||||||
textShadow:
|
<span
|
||||||
"0 4px 30px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.25)",
|
key={i}
|
||||||
}}
|
className="almanac-mono text-[11px] tracking-[0.24em] uppercase px-6 shrink-0 text-[#f3ece0]/85 whitespace-nowrap"
|
||||||
>
|
|
||||||
{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)" }}
|
|
||||||
>
|
>
|
||||||
{item.stat}
|
{t}
|
||||||
</div>
|
</span>
|
||||||
<div
|
))}
|
||||||
className="text-[10px] uppercase tracking-[0.25em] mt-1"
|
</div>
|
||||||
style={{ color: "rgba(255,255,255,0.55)" }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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