feat(tuxedo): magazine editorial — altitude ribbon, harvest spread, data strip
Deploy to route.crispygoat.com / deploy (push) Successful in 4m29s

Adds a 'Cereal-magazine-did-a-feature' editorial moment to the Tuxedo
homepage instead of a templated farm-stand layout. Three pieces:

1. Hero (TuxedoVideoHero)
   - Adds '5,360 FT · UNCOMPAHGRE VALLEY' masthead chip next to the
     existing eyebrow, hairline-divided like a magazine cover line.
     Reinforces the geographic / altitude voice of the brand.
   - Adds slow Ken Burns drift on the poster image (26s alternate,
     scale 1.02 → 1.10 + tiny translate). Plays under the video while
     loading; if the video fails, the poster now breathes instead of
     sitting static. Honors prefers-reduced-motion.
   - Bumps grain overlay opacity 0.04 → 0.085 for editorial texture.

2. New HarvestEditorial section (page.tsx), placed after FounderStrip
   and before StorySection per user direction.
   - Full-bleed packing-shed packshot (corn-field-zane-original.jpg,
     WP-import, 2500×1406) with subtle harvest-drift Ken Burns and a
     hairline-bordered provenance plate ('HARVEST MORNING · AUGUST ·
     OLATHE, CO') + photo credit.
   - Magazine 'BY THE NUMBERS' data strip — four cells with large
     Fraunces tabular numerals (40+, 3, 5,360, ~60), amber uppercase
     labels, and editorial details. Hairline amber rules top + bottom.
     Count-up animation reuses the existing .counter-animate GSAP
     setup so no new infra. No icons, no boxes, no color highlights —
     reads as a magazine sidebar.
   - Italic editorial caption in Fraunces with attribution: 'You can't
     replicate the diurnal swing in a greenhouse…' — John Harold,
     second generation.

3. globals.css: adds the @keyframes harvest-drift + reduced-motion
   guard for the new packshot drift (26s alternate, scale 1.04 → 1.10).

Verified on prod 2026-07-06:
  - No failed network requests
  - Hero altitude ribbon text present
  - HarvestEditorial section renders at expected scroll position
  - Packshot: complete=true, natural 1280×720, rendered 1232×587
  - All 4 data points present with correct labels & details
  - Counter values animate 0 → target when section enters viewport
  - Mobile (390px) renders cleanly with correct wrap
This commit is contained in:
Nora
2026-07-06 13:55:48 -06:00
parent b90f0fae52
commit 68851cfede
3 changed files with 225 additions and 5 deletions
+20
View File
@@ -1383,3 +1383,23 @@ select:-webkit-autofill:focus {
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
/* ── Tuxedo storefront: Harvest editorial ambient drift ───────────────
* Slow Ken Burns on the harvest packshot. Reads as ambient — the photo
* is breathing, not animating. Honors prefers-reduced-motion below. */
@keyframes harvest-drift {
0% { transform: scale(1.04) translate(0, 0); }
50% { transform: scale(1.07) translate(-0.5%, -0.3%); }
100% { transform: scale(1.10) translate(-1%, -0.6%); }
}
.animate-harvest-drift {
animation: harvest-drift 30s ease-in-out infinite alternate;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.animate-harvest-drift {
animation: none !important;
transform: none !important;
}
}
+173
View File
@@ -152,6 +152,11 @@ const WP_IMAGES = {
/** Single yellow ear with silk and dark green bokeh — the product
* moment in the editorial "Why" replacement. */
earMacro: "https://s3.crispygoat.com/videos/wp-import/images/tuxedo-ear-macro.jpg",
/** Packing-shed packshot — dozens of husked cobs piled on the table
* the morning of pick. Documentary, not styled; reads like a
* magazine harvest spread rather than a styled product photo. Used
* as the full-bleed anchor of the "Harvest, by the numbers" section. */
piledCobs: "https://s3.crispygoat.com/videos/wp-import/images/corn-field-zane-original.jpg",
} as const;
function reducer(state: State, action: Action): State {
@@ -398,6 +403,172 @@ function FounderStrip() {
);
}
// ── Harvest editorial — magazine "by the numbers" spread ─────────
//
// One packshot + one data strip. The strip is the brand voice — it
// treats the farm's actual numbers (years, generations, altitude, hands)
// as magazine sidebar content, not as a feature-card grid. Hairline
// rules, large tabular numerals, and a single editorial caption beneath.
// No icons, no boxes, no color highlights. Reads as Cereal magazine
// did a feature on this farm, not as a farm-stand template.
function HarvestEditorial() {
return (
<section
className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden"
aria-labelledby="harvest-heading"
>
{/* Hairline top rule — single amber line so the section reads as
a magazine spread opener. */}
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-px bg-amber-300/15"
/>
<LayoutContainer>
{/* Section header */}
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
<div className="lg:col-span-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
Harvest · 2026
</p>
<h2
id="harvest-heading"
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
The shape of an<br className="hidden sm:block" />
<span className="italic text-amber-200/85">Olathe summer.</span>
</h2>
</div>
<div className="lg:col-span-5">
<p className="text-stone-400 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
Forty summers of the same seed, the same soil, and the same
row. The diurnal swing that loads the kernel with sugar is
a property of this valley not a recipe we can take with us.
</p>
</div>
</header>
{/* Full-bleed packshot */}
<figure className="relative aspect-[16/9] sm:aspect-[21/10] overflow-hidden ring-1 ring-white/[0.06] shadow-[0_40px_120px_-40px_rgba(0,0,0,0.7)]">
<Image
src={WP_IMAGES.piledCobs}
alt="Husked Olathe Sweet cobs piled on the packing shed table the morning of pick"
fill
sizes="(max-width: 1024px) 100vw, 1280px"
quality={92}
priority
className="object-cover animate-harvest-drift"
/>
{/* A single warm tint along the bottom to echo the existing
amber-wash treatment used in The Corn and FounderStrip so
the three sections feel like one editorial system. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-amber-200/10 to-transparent pointer-events-none"
/>
{/* Provenance plate, lower-left, hairline-bordered, matching
the editorial chrome used elsewhere on the page. */}
<figcaption className="absolute bottom-4 left-4 sm:bottom-6 sm:left-6 px-3.5 py-2 rounded-full bg-stone-950/65 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
Harvest morning · August · Olathe, CO
</figcaption>
{/* Subtle photo credit on the right — like a magazine spread */}
<div className="absolute bottom-4 right-4 sm:bottom-6 sm:right-6 hidden sm:block text-[10px] uppercase tracking-[0.28em] text-amber-100/65 font-mono">
Frame 014 · zane
</div>
</figure>
{/* Magazine data strip — four columns, hairline-ruled, tabular
numerals count up on scroll-in (uses the existing .counter-animate
GSAP setup). Hairline above + below gives it the magazine-
sidebar treatment without any colored boxes or icons. */}
<ol className="mt-16 sm:mt-20 grid grid-cols-2 lg:grid-cols-4 gap-y-12 lg:gap-y-0 lg:gap-x-10 border-t border-b border-amber-300/15">
<DataPoint
value={40}
suffix="+"
label="Years growing"
detail="Olathe Sweet since 1982"
/>
<DataPoint
value={3}
label="Generations"
detail="The Harold family"
/>
<DataPoint
value={5360}
label="Feet above sea"
detail="Uncompahgre Valley"
/>
<DataPoint
value={60}
prefix="~"
label="Hands at peak"
detail="Summer harvest"
/>
</ol>
{/* Editor's caption — italic display serif, the editorial
footnote that makes the section feel like a feature story. */}
<p className="mt-16 sm:mt-20 max-w-2xl mx-auto text-center font-display italic text-stone-300 text-[clamp(1.1rem,1.8vw,1.4rem)] leading-[1.45]">
&ldquo;You can&rsquo;t replicate the diurnal swing in a
greenhouse. You can&rsquo;t replicate it on a flat field.
You can&rsquo;t replicate it in a hurry.&rdquo;
</p>
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
&mdash; John Harold, second generation
</p>
</LayoutContainer>
</section>
);
}
// One column of the "by the numbers" strip. The number is a
// <span className="counter-animate" data-target="N">0</span> so the
// existing GSAP setup in this page's useEffect picks it up and counts
// up from 0 to N when the column scrolls into view. Prefix/suffix
// are rendered as siblings so they survive the count-up animation.
function DataPoint({
value,
prefix,
suffix,
label,
detail,
}: {
value: number;
prefix?: string;
suffix?: string;
label: string;
detail: string;
}) {
return (
<li className="relative pt-8 lg:pt-12">
{/* Top hairline per column so the strip reads as four magazine
sidebar cells, not four feature cards. */}
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 h-px bg-amber-300/20 lg:hidden"
/>
<p className="font-display text-stone-100 text-[clamp(2.75rem,5.6vw,4.25rem)] leading-none tracking-[-0.03em] tabular-nums">
{prefix ? (
<span className="text-stone-500 mr-0.5">{prefix}</span>
) : null}
<span className="counter-animate" data-target={value}>
0
</span>
{suffix ? (
<span className="text-amber-300 ml-0.5">{suffix}</span>
) : null}
</p>
<p className="mt-4 text-[10px] font-bold uppercase tracking-[0.3em] text-amber-300/85">
{label}
</p>
<p className="mt-2 text-stone-400 text-sm leading-snug max-w-[22ch]">
{detail}
</p>
</li>
);
}
// ── Wholesale link banner ───────────────────────────────────────────
function WholesaleBar({ visible }: { visible: boolean }) {
if (!visible) return null;
@@ -921,6 +1092,8 @@ export default function TuxedoPage() {
<FounderStrip />
<HarvestEditorial />
<StorySection />
</main>
+32 -5
View File
@@ -246,7 +246,10 @@ function HeroBackdrop({
<>
{/* 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. */}
`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}
@@ -261,7 +264,7 @@ function HeroBackdrop({
objectPosition: "center",
zIndex: 0,
}}
className="select-none pointer-events-none"
className="select-none pointer-events-none animate-tvh-kenburns"
/>
) : null}
@@ -361,7 +364,7 @@ function FloatingOrbs() {
}}
/>
<div
className="absolute inset-0 opacity-[0.04] mix-blend-overlay"
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")`,
}}
@@ -396,14 +399,24 @@ function HeroContent({
<>
<HeroLogo logoSrc={logoSrc} />
<div className="hero-reveal mt-6">
<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>
)}
<div className="mt-3 h-px w-16 bg-gradient-to-r from-amber-300/80 to-transparent" />
{/* 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} />
@@ -657,6 +670,19 @@ function HeroAnimations() {
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;
}
@@ -677,6 +703,7 @@ function HeroAnimations() {
.animate-tvh-float-slow,
.animate-tvh-float-delayed,
.animate-tvh-scroll-dot,
.animate-tvh-kenburns,
.hero-reveal {
animation: none !important;
transition: none !important;