feat(tuxedo): Field Almanac editorial composition
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:
Tyler
2026-07-07 09:10:40 -06:00
parent c71354d2ae
commit ffdc80e60e
4 changed files with 1467 additions and 1470 deletions
+249
View File
@@ -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;
}
+3 -8
View File
@@ -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>
+896 -751
View File
File diff suppressed because it is too large Load Diff
+239 -631
View File
@@ -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({
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 (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: "bottom top",
scrub: true,
onUpdate: (self) => {
dispatch({ type: "SET_SCROLL_PROGRESS", value: self.progress });
},
});
if (contentRef.current) {
gsap.to(contentRef.current, {
opacity: 0,
ease: "power1.out",
gsap.to(".almanac-cover-fade", {
opacity: 0.35,
ease: "none",
scrollTrigger: {
trigger: sectionRef.current,
start: "top top",
end: "40% top",
scrub: 1.2,
},
});
}
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(".scroll-indicator", {
opacity: 0,
scrollTrigger: {
trigger: sectionRef.current,
start: "10% top",
end: "30% top",
scrub: 1,
end: "60% top",
scrub: true,
},
});
}, 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 min-h-screen flex items-end overflow-hidden isolate bg-stone-950"
className="relative almanac-paper almanac-grain overflow-hidden"
>
<HeroBackdrop
videoUrl={videoUrl}
posterUrl={posterUrl}
videoRef={videoRef}
onVideoStateChange={setVideoPlaying}
/>
<div className="crop-marks hidden md:block">
<span />
</div>
<Masthead />
<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 &nbsp;·&nbsp; Issue 03 &nbsp;·&nbsp; 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
ref={contentRef}
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-24 md:pb-32 pt-32"
aria-hidden
className="almanac-pagenum absolute -top-6 md:-top-12 -left-2 md:-left-6 select-none"
>
<div className="max-w-3xl">
<HeroContent
logoSrc={logoSrc ?? null}
eyebrow={eyebrow}
title={title}
description={description}
primaryButton={primaryButton}
secondaryButton={secondaryButton}
onPrimaryClick={handlePrimaryClick}
onSecondaryClick={onSecondaryClick}
/>
</div>
XL
</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 ? (
{olatheSweetLogoUrl ? (
<div className="relative h-12 md:h-14 w-[260px] md:w-[320px] mb-6">
<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} />
<HeroCTAs
primaryButton={primaryButton}
secondaryButton={secondaryButton}
onPrimaryClick={onPrimaryClick}
onSecondaryClick={onSecondaryClick}
/>
<HeroStats />
</>
);
}
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
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}
src={olatheSweetLogoUrl}
alt="Olathe Sweet"
fill
sizes="(max-width: 1024px) 80vw, 50vw"
style={{ objectFit: "contain" }}
className="drop-shadow-2xl"
sizes="(max-width: 768px) 80vw, 320px"
style={{ objectFit: "contain", objectPosition: "left center" }}
priority
/>
</div>
) : null}
</motion.div>
);
}
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.
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)",
}}
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}
{title.split(" ").map((word, i) => (
<span key={`${i}-${word}`} className="word inline-block mr-[0.18em]">
{word}
</span>
))}
</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 }}
>
<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>
);
}
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">
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
{primaryButton && (
<PrimaryButton label={primaryButton} onClick={onPrimaryClick} />
<button type="button" onClick={onPrimaryClick} className="almanac-btn-primary">
<span>{primaryButton}</span>
<span aria-hidden></span>
</button>
)}
{secondaryButton && (
<SecondaryButton label={secondaryButton} onClick={onSecondaryClick} />
<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 &nbsp;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">
&ldquo;The best sweet corn doesn&rsquo;t travel. It shouldn&rsquo;t have to.&rdquo;
</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>
<Ticker />
<style jsx>{`
.word {
will-change: opacity, transform;
}
`}</style>
</section>
);
}
function PrimaryButton({ label, onClick }: { label: string; onClick: () => void }) {
// ─────────────────────────────────────────────────────────────────────────────
// MASTHEAD — newspaper-style running header
// ─────────────────────────────────────────────────────────────────────────────
function Masthead() {
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>
<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>
</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 className="text-[#1a1814]/25">·</span>
<span className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
5,360 ft
</span>
</button>
<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&nbsp;Harold&nbsp;Almanac
</div>
<div className="almanac-mono text-[10px] tracking-[0.22em] uppercase text-[#1a1814]/55">
Sat · 8 Aug MMXXVI
</div>
</div>
</div>
</header>
);
}
function HeroStats() {
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<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>
);
}
// ── 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;
}
}
`,
}}
/>
);
}