Files
eranos/src/components/HeroAtmosphere.tsx
T
2026-05-20 23:28:17 -07:00

127 lines
4.7 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { hopeHueFor, type HopeHue } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
interface HeroAtmosphereProps {
/**
* Stable seed for the current campaign — typically the campaign's
* `aTag`. The same seed always picks the same hue from
* {@link HOPE_PALETTE}. Pass `null`/`undefined` when no campaign is
* spotlit and the atmosphere will default to the first palette entry.
*
* Ignored when `hue` is provided. Optional only when `hue` is given;
* the campaign hero still depends on seed-based selection.
*/
seed?: string | undefined | null;
/**
* Explicit hue override. When set, the atmosphere skips seed-based
* palette selection and crossfades whenever this hue changes. Use this
* when the page already rotates hues itself (e.g. the Organize hero
* cycles a cool palette every few seconds) or when the seed-derived
* warm palette is the wrong vibe.
*/
hue?: HopeHue;
/** Crossfade duration in milliseconds. Defaults to the campaign hero timing. */
fadeMs?: number;
/** Extra classes for the outer wrapper. */
className?: string;
}
interface AtmosphereLayer {
/** Render-order id so React doesn't tear the layer down mid-fade. */
id: number;
hue: HopeHue;
}
/** Has to match {@link CampaignHeroBackground}'s FADE_MS so the entire
* hero transitions as a single moment instead of in two staggered steps. */
const FADE_MS = 1500;
/**
* Soft, hue-tinted "sunrise" atmosphere layer for the campaigns hero.
*
* Two layered gradients sit on top of the photo background:
* - a left-to-right warm scrim that gives the headline area an emotional
* color cast, and
* - a large soft radial glow centered on the headline that reads as a
* sunrise / dawn light pooling behind the text.
*
* The hue is derived from {@link hopeHueFor} so every campaign gets a
* stable, slightly different warm color. When the active campaign
* changes we mount a fresh layer with the new hue and crossfade it over
* the old one, matching the timing of the photo crossfade so the whole
* hero blooms together.
*/
export function HeroAtmosphere({ seed, hue: hueOverride, fadeMs = FADE_MS, className }: HeroAtmosphereProps) {
const idRef = useRef(0);
const [layers, setLayers] = useState<AtmosphereLayer[]>([]);
const lastHueRef = useRef<string | null>(null);
useEffect(() => {
const hue = hueOverride ?? hopeHueFor(seed ?? null);
if (hue.name === lastHueRef.current) return;
lastHueRef.current = hue.name;
const id = ++idRef.current;
setLayers((prev) => [...prev, { id, hue }]);
// Drop everything except the most recent layer once the crossfade is
// safely past, so the DOM never accumulates stale gradients.
const timeout = window.setTimeout(() => {
setLayers((prev) => prev.filter((l) => l.id === id));
}, fadeMs + 50);
return () => window.clearTimeout(timeout);
}, [seed, hueOverride, fadeMs]);
return (
<div className={cn('absolute inset-0 pointer-events-none', className)} aria-hidden="true">
{layers.map((layer, i) => {
const isTop = i === layers.length - 1;
return (
<div
key={layer.id}
className="absolute inset-0"
style={{
opacity: isTop ? 1 : 0,
transition: `opacity ${fadeMs}ms ease-in-out`,
}}
>
{/* Warm directional scrim — pulls the photo toward the active
hue without flattening it. Anchored on the left so the
headline area gets the strongest tint. */}
<div
className="absolute inset-0"
style={{
backgroundImage: `linear-gradient(115deg, ${layer.hue.scrim} 0%, transparent 60%)`,
}}
/>
{/* Big soft radial glow — reads as dawn light pooling behind
the headline. mix-blend-screen so it lightens warmly
instead of just adding a flat color. */}
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(45rem 32rem at 20% 35%, ${layer.hue.glow} 0%, transparent 70%)`,
mixBlendMode: 'screen',
}}
/>
{/* Thin sliver of sunrise light along the top edge. Subtle —
you should feel it more than see it. */}
<div
className="absolute inset-x-0 top-0 h-1/3"
style={{
backgroundImage: `linear-gradient(to bottom, ${layer.hue.rim} 0%, transparent 100%)`,
mixBlendMode: 'screen',
opacity: 0.55,
}}
/>
</div>
);
})}
</div>
);
}