Skip to content

Commit cbf39b2

Browse files
committed
Hero: 30-phrase daily rotation + themed-day overrides + slower drift
Three changes packaged together: 1. Daily phrase rotation. New src/lib/hero-phrases.ts holds 30 phrases in three groups (invitations, place/atmosphere, time/scope). Two phrases compute live counts from the manifest so they stay accurate as content grows ("12 dawn choruses", "150+ voices"). Picked deterministically per calendar day. 2. Themed-day overrides. New src/lib/themed-days.ts. Recurring anniversaries keyed by MM-DD (solstices, Earth Day, Halloween for the owls, etc.) and specific multi-day windows keyed by YYYY-MM-DD (Pt. Reyes Birding Festival, currently scheduled Apr 16-18 2027 — updated annually as new dates are announced). When a themed day matches today, its phrase replaces the rotation pick. 3. Shared daily-pick helper. New src/lib/daily-pick.ts factors out the FNV-1a + todayStr() + dailyPick() pattern that BirdOfTheDay already used. The hero rotation salts its pick with "hero" so the index varies independently of the bird-of-the-day picker. 4. Hero fog drift slowed by ~2x — back layer 60s -> 120s/cycle, mid-back 45s -> 90s/cycle. Was fine but borderline noticeable; doubled durations push it firmly into "atmosphere not motion".
1 parent 1a13499 commit cbf39b2

4 files changed

Lines changed: 184 additions & 5 deletions

File tree

src/components/Hero.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1+
import { useMemo } from "react";
12
import { motion, useReducedMotion } from "framer-motion";
3+
import { dailyPick } from "@/lib/daily-pick";
4+
import { HERO_PHRASES, resolvePhrase } from "@/lib/hero-phrases";
5+
import { pickThemedDay } from "@/lib/themed-days";
26

37
/**
48
* Restrained atmospheric band that sits below the sticky StickyTopBar.
59
* Decorative-only: small caps label + display headline + coordinates +
610
* four layers of rolling hills + tiny bird silhouettes. No interactive
711
* controls — those live in StickyTopBar so they pin to the top on scroll.
812
*
13+
* The headline rotates daily through HERO_PHRASES; calendar-themed days
14+
* (solstices, Earth Day, the Pt. Reyes Birding Festival window) override
15+
* the rotation when they match. Pick is deterministic per calendar day.
16+
*
917
* The back two fog layers drift slowly horizontally (parallax depth);
1018
* the front two stay static so the foreground doesn't pull the eye away
1119
* from the title block above. Drift is disabled under
@@ -23,6 +31,15 @@ export function Hero() {
2331
transition: { duration: durationSec, ease: "linear" as const, repeat: Infinity, repeatType: "loop" as const },
2432
};
2533

34+
// Daily phrase: themed override beats rotation. Memoized once per mount —
35+
// page won't shift the headline mid-session, only on a future day's load.
36+
const phrase = useMemo(() => {
37+
const themed = pickThemedDay();
38+
if (themed) return themed.phrase;
39+
const pick = dailyPick(HERO_PHRASES, "hero");
40+
return pick ? resolvePhrase(pick) : "Pick a habitat.";
41+
}, []);
42+
2643
return (
2744
<section className="relative -mx-5 mb-2 overflow-hidden border-b border-(--color-line) bg-gradient-to-b from-(--color-bg) via-(--color-sand-50)/40 to-(--color-moss-50)/60 px-5 pb-24 pt-2 sm:pb-28 dark:via-(--color-sand-50)/30 dark:to-(--color-moss-50)/40">
2845
{/* Tiny decorative birds, upper-left */}
@@ -40,7 +57,7 @@ export function Hero() {
4057
West Marin · Ear Training
4158
</p>
4259
<h1 className="mt-2 font-display text-4xl leading-[1.05] tracking-tight sm:text-5xl">
43-
Pick a habitat.
60+
{phrase}
4461
</h1>
4562
<p className="mt-2 font-mono text-[10px] tracking-[0.2em] text-(--color-ink-soft)" title="Paper Mill Creek Saloon, Forest Knolls">
4663
38°00′52″N · 122°41′44″W
@@ -59,9 +76,9 @@ export function Hero() {
5976
viewBox="0 0 800 100"
6077
preserveAspectRatio="none"
6178
>
62-
{/* Back fog — palest, broad waves, slowest drift (60s/cycle).
79+
{/* Back fog — palest, broad waves, slowest drift (120s/cycle).
6380
Path duplicated at +800 so the loop seam is invisible. */}
64-
<motion.g {...drift(60)}>
81+
<motion.g {...drift(120)}>
6582
<path
6683
d="M0,48 Q 100,22 200,48 T 400,48 T 600,48 T 800,48 L 800,100 L 0,100 Z"
6784
fill="oklch(93% 0.03 160 / 0.55)"
@@ -73,8 +90,8 @@ export function Hero() {
7390
/>
7491
</motion.g>
7592
{/* Mid-back — phase-shifted so its peaks fall in back's valleys.
76-
Faster drift (45s) than back layer for parallax depth. */}
77-
<motion.g {...drift(45)}>
93+
Faster drift (90s) than back layer for parallax depth. */}
94+
<motion.g {...drift(90)}>
7895
<path
7996
d="M0,64 Q 80,42 180,62 T 380,62 T 580,62 T 800,64 L 800,100 L 0,100 Z"
8097
fill="oklch(85% 0.04 160 / 0.7)"

src/lib/daily-pick.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Date-seeded deterministic picks. The same calendar day always returns
3+
* the same result, so refresh-stability is automatic and there's no
4+
* need for any persistence layer.
5+
*
6+
* Used by:
7+
* - BirdOfTheDay (species pool, no salt)
8+
* - Hero phrase rotation (phrase pool, salt = "hero")
9+
* - anything else that wants "today's X" picked from a stable pool
10+
*/
11+
12+
/** Deterministic FNV-1a-ish hash of a string → non-negative 32-bit int. */
13+
export function hashStr(s: string): number {
14+
let h = 2166136261;
15+
for (let i = 0; i < s.length; i++) {
16+
h ^= s.charCodeAt(i);
17+
h = Math.imul(h, 16777619);
18+
}
19+
return h >>> 0;
20+
}
21+
22+
/**
23+
* "YYYY-M-D" — note: NOT zero-padded. Don't change the format casually;
24+
* any existing date hashes (BirdOfTheDay, etc.) depend on it.
25+
*/
26+
export function todayStr(d: Date = new Date()): string {
27+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
28+
}
29+
30+
/**
31+
* Stable daily pick from a pool. The optional `salt` lets multiple
32+
* pickers vary independently on the same date — pass different salts
33+
* (e.g. "hero" vs "" for bird-of-the-day) so the indexes don't lock
34+
* in lockstep.
35+
*/
36+
export function dailyPick<T>(pool: readonly T[], salt = ""): T | null {
37+
if (pool.length === 0) return null;
38+
return pool[hashStr(todayStr() + salt) % pool.length];
39+
}

src/lib/hero-phrases.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* The 30 phrases that rotate through the Hero section's headline slot.
3+
* Picked deterministically per calendar day via dailyPick().
4+
*
5+
* Mix of:
6+
* - imperatives / invitations (1-10)
7+
* - place & atmosphere (11-20) — leans West-Marin-specific
8+
* - time, scope, meta (21-30) — counts read live from the manifest
9+
*
10+
* Phrases can be plain strings OR thunks that compute live values
11+
* from the manifest (so habitat / species counts stay accurate as
12+
* content grows).
13+
*/
14+
15+
import { units, allSpeciesWithRecordings } from "./manifest";
16+
17+
type Phrase = string | (() => string);
18+
19+
const roundDown = (n: number, to: number) => Math.floor(n / to) * to;
20+
21+
export const HERO_PHRASES: Phrase[] = [
22+
// Invitations
23+
"Pick a habitat.",
24+
"Open your ears.",
25+
"Listen, then look.",
26+
"Whose call is that?",
27+
"Know that one?",
28+
"Can you place it?",
29+
"Tune your ear.",
30+
"Start anywhere.",
31+
"Learn the locals.",
32+
"Bring your morning ears.",
33+
34+
// Place & atmosphere
35+
"The hills are talking.",
36+
"The fog is talking.",
37+
"The marsh is awake.",
38+
"The redwoods speak first.",
39+
"The wood is singing.",
40+
"Quail country.",
41+
"Wrentit weather.",
42+
"Pasture light.",
43+
"Coast to ridge.",
44+
"From driveway to ridgetop.",
45+
46+
// Time, scope, meta — counts live from the manifest so they
47+
// stay accurate as content grows.
48+
() => `${units.length} dawn choruses.`,
49+
() => `${roundDown(allSpeciesWithRecordings().length, 10)}+ voices.`,
50+
"Two-note morning.",
51+
"Dawn is loud.",
52+
"Owl-light hour.",
53+
"Coffee, then call notes.",
54+
"Pre-dawn, post-coffee.",
55+
"A song for every habitat.",
56+
"Listen at home; watch in the field.",
57+
"Your ears, the field guide.",
58+
];
59+
60+
/** Resolve a phrase to a string (calling thunks if present). */
61+
export function resolvePhrase(p: Phrase): string {
62+
return typeof p === "function" ? p() : p;
63+
}

src/lib/themed-days.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Curated calendar-date overrides for the Hero phrase. When a themed day
3+
* matches today's date, its phrase appears INSTEAD of the daily-rotation
4+
* pick. Used for:
5+
*
6+
* - Civic / nature dates that recur every year (Earth Day, solstices,
7+
* Halloween for the owls). Keyed by "MM-DD".
8+
* - Specific multi-day windows that move year-to-year (Pt. Reyes
9+
* Birding Festival, etc.). Keyed by "YYYY-MM-DD" range and need
10+
* manual annual updates.
11+
*/
12+
13+
export interface ThemedDay {
14+
phrase: string;
15+
/** Optional: also pin the bird-of-the-day to this species id. */
16+
speciesId?: string;
17+
}
18+
19+
/** Anniversaries that fire every year. Keyed by "MM-DD". */
20+
export const THEMED_EXACT_DAYS: Record<string, ThemedDay> = {
21+
"01-01": { phrase: "A new year of birds." },
22+
"02-14": { phrase: "Send a love song." },
23+
"03-20": { phrase: "First day of spring; first songs." },
24+
"04-22": { phrase: "Earth Day. Listen up." },
25+
"06-21": { phrase: "Solstice morning. Long, loud chorus." },
26+
"10-31": { phrase: "Owls all night." },
27+
"12-21": { phrase: "Solstice. The owls have it." },
28+
"12-25": { phrase: "Even today, the birds are out." },
29+
};
30+
31+
/**
32+
* Multi-day windows. Update annually as event dates are announced.
33+
* `from` and `to` are inclusive "YYYY-MM-DD" strings.
34+
*
35+
* Pt. Reyes Birding & Nature Festival — pointreyesbirdingfestival.org.
36+
* Three-day weekend in mid-to-late April. Past:
37+
* 2026: Apr 24–26
38+
* 2027: Apr 16–18 ← scheduled
39+
*/
40+
export const THEMED_RANGES: Array<{ from: string; to: string; day: ThemedDay }> = [
41+
{ from: "2027-04-16", to: "2027-04-18",
42+
day: { phrase: "The Pt. Reyes Birding Festival is on." } },
43+
];
44+
45+
/** Pick today's themed override, if any. Returns null on a regular day. */
46+
export function pickThemedDay(d: Date = new Date()): ThemedDay | null {
47+
const mm = String(d.getMonth() + 1).padStart(2, "0");
48+
const dd = String(d.getDate()).padStart(2, "0");
49+
50+
// Anniversaries first
51+
const exact = THEMED_EXACT_DAYS[`${mm}-${dd}`];
52+
if (exact) return exact;
53+
54+
// Then specific-year ranges
55+
const today = `${d.getFullYear()}-${mm}-${dd}`;
56+
for (const r of THEMED_RANGES) {
57+
if (today >= r.from && today <= r.to) return r.day;
58+
}
59+
return null;
60+
}

0 commit comments

Comments
 (0)