Feature Deep-Dive · Homepage Hero System

Every hero puts a real player on screen.
The face always means something.

The homepage hero isn't decoration. A rules engine decides which player's face appears on any given day, and a small image pipeline decides how it’s rendered — a free ESPN cutout composited over his NFL team's colors, personalized to the signed-in owner, and stable for a full Pacific-Time day. This is how it works.

Astro SSR · Deterministic casting · CSS composites over ESPN CDN · 14-phase state machine · Light + dark
01

The Premise

Why there's a real player on the hero at all — and the one rule that governs it

Identifiable NFL action photography is rights-managed — Getty, AP, Imagn. A fantasy site can't ship it. The easy fallback is a gradient with a headline slapped on top, which is exactly how most fantasy homepages look: fine, forgettable, decorative.

The approach here is different. Every NFL player has a free, transparent headshot on ESPN's CDN. Layer that cutout over his team's colors with a little typography and glow, and you get a branded, on-topic hero — automatically, for any player, with zero licensing. The constraint became the design language.

That unlocks the actual rule, which is the whole point of the system:

Casting Rule — Brandon, binding
“Every hero casts a semantically relevant player — the actual player in a breaking story, the rookie behind a new feature, a bubble player from your roster on cut watch. Never a random star for decoration.”
02

The Image Pipeline

How a name becomes a composite — and the gate that keeps it from breaking

z-0
Team-color gradient + ghost wordmark
A diagonal gradient (league-blue base) with an oversized, barely-there wordmark — WHAT’S NEW, AUCTION, CUT WATCH — bled off the top edge.
z-1
Radial glow, tinted by NFL team color
hexToRgba(primary, 0.38) in dark, 0.2 in light — the player’s team hue bleeds in from the corner behind him.
z-2
Transparent ESPN headshot cutout
Anchored bottom-right with a soft drop-shadow. On the real homepage hero it’s loading="eager" (above the fold); the reproductions above lazy-load. Always decoding="async", decorative alt="".
z-3
Frosted caption + editorial copy
Player name on a backdrop-filter: blur(2px) pill bottom-right; kicker, title, summary, CTA bottom-left with an explicit text color so global heading rules can’t restyle it.
src/utils/hero-casting.ts
/** Composites need a transparent ESPN cutout —
    MFL JPGs have baked backgrounds. */
function isCompositable(player: PlayerIdentity): boolean {
  return player.position !== 'DEF'
    && isEspnCdnUrl(player.headshot);
}

Two failure modes get gated out before anything renders. Transparency is load-bearing: the ESPN headshot has an alpha channel; MFL's fallback JPG (player_photos_big_2014/…_thumb.jpg) has a baked-in background that ruins the composite. So the gate requires an ESPN CDN URL — matched on the parsed host, never a substring, so a lookalike like espncdn.com.evil.com can’t slip a foreign image into an <img src>. And a team defense isn’t a person — position === ‘DEF’ is excluded, since a "DEF player" is a logo, not a face.

Even a valid ESPN id can 404 (deep free agents sometimes have no photo). The cutout carries an inline onerror that hides itself and its caption, and the hero swaps to the league-logo silhouette — a gradient with an empty flank reads as broken, so it never ships one.

DEF → excludedMFL JPG → excluded404 → league-logo silhouettetabular-nums on every metricprefers-reduced-motion guarded
03

Branding & Color

The player's NFL team paints the hero; his fantasy team rides alongside

Color comes from one source of truth: a 32-team NFL brand-color map (NFL_TEAM_COLORS, keyed by ESPN codes) with a hand-picked primary and secondary per team — secondary deliberately skips near-white "official" colors that would be useless on a dark composite. The player’s NFL team drives the gradient anchor and the glow tint. Unknown or free-agent codes fall back to TheLeague blue, #1c497c.

Rostered player

The hero is about a fantasy team, so franchise identity rides alongside the NFL color — the owner’s crest and short name as a chip, a “You +N” marker when it’s your own player. Cut watch and the tagged-player showcase are the roster-branded surfaces.

franchise crest · chooseTeamName(…, 'short')
Free agent / unrostered

No fantasy team to brand to, so the hero leans on NFL identity — the team-color gradient and glow, the league logo as backdrop art on the live auction. Auction and UDFA are the free-agent surfaces; deep FAs fall back to league blue.

NFL_TEAM_COLORS · #1c497c fallback
BAL #241773
CIN #fb4f14
CHI #0b162a
ATL #a71930
KC #e31837
FA #1c497c
04

Personalization Without Randomness

Signed-in owners see their own player; guests see a deterministic public pick

Same layout, different casting. The roster-action and kickoff heroes take an optional userFranchiseId. When it’s set and the owner has a relevant candidate, the pool narrows to their own players — your bubble cut, your kickoff starter, your suggested tag. When it’s empty (a guest, or an owner with no candidate), the hero falls back to a league-wide pick.

The catch: this is server-rendered, and SSR can’t call Math.random() at request time — two loads of the same page would disagree, and the cache would be useless. So the pick is deterministic per Pacific-Time calendar day: an FNV-1a hash of the PT day-key plus a per-hero seed indexes into the sorted candidate pool. Stable across every request that day, then it rotates at midnight PT so one face doesn’t own the window.

It’s testable, too — ?testDate=YYYY-MM-DD drives both the phase and the rotation seed, so any day’s hero can be previewed on demand.

The deterministic daily pick
// PT calendar day (YYYY-MM-DD) — the rotation seed
function ptDayKey(date) {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: 'America/Los_Angeles', …
  }).format(date);
}

function dailyPick(pool, date, seedKey, keyOf) {
  if (pool.length === 0) return null;
  const sorted = [...pool].sort(
    (a, b) => keyOf(a).localeCompare(keyOf(b)));
  const i = hashSeed(`${ptDayKey(date)}:${seedKey}`)
            % sorted.length;
  return sorted[i];        // same all day, rotates at PT midnight
}
Signed-in owner
Cut watch → your suggested cut, chip reads “You +2”
Kickoff → your best starter in the opener
Pool narrows to franchiseId === userFranchiseId
Guest
Cut watch → a league-wide bubble candidate
Kickoff → the opener's highest projection
Deterministic daily pick from the full pool
05

The Casting Rules, by Category

What's New heroes show the feature itself — a player appears only when the entry names one

entry with screenshot
The feature itself
The entry's screenshot in a browser frame — light/dark capture pair, hidden on mobile where the logo silhouette stands in.
entry naming a player
That exact player
heroPlayerId on the entry casts him over the gradient — players show up when they’re being talked about, never as stand-ins.
bug-fix
No player
League-logo silhouette (light/dark asset pair). A bug fix doesn’t get a face.
league-event
Relevant player
The player who’s actually in the story. (Event-hero conversions in progress.)
06

Eight Casting Functions

Each surface has a purpose-built picker in hero-casting.ts — all deterministic, all reusable

FunctionSurfaceWho it picks
castFeaturedModelWhat’s New (entry names a player)Exactly the player the entry features — otherwise the hero shows the feature’s own screenshot.
castRookieModelSeason reset (AFL new-season)Newest draft class in the pool, rostered-first, daily rotation.
castTopFreeAgentModelAuction preview / liveTop-5 unrostered free agents by dynasty ADP, rotated daily.
castClosingAuctionModelAuction, live bidsThe player whose bid clock runs out first (oldest anchor + MFL’s 36h rule).
castRookiesOnBoardUDFA windowBest current-class rookies still unrostered — the board is the board (no rotation).
castBestScoredModelPreseason / kickoffHighest-projected starter in the earliest game — your player if you roster one.
castRandomStarterModelPreseason / kickoff / game dayRotates among YOUR likely starters (top-8/team by projection) in the first game you play — your opener player if you have one. Guests get the opener, league-wide.
castRosterModelRoster actions (cuts, tags)A candidate from YOUR franchise; guests get a league-wide daily pick.
07

The Calendar Decides

14 core season phases resolve by date — plus a trade-deadline override and an offseason fallback; the two live phases subdivide into 6 daily slots

Which hero shows isn’t a toggle — it’s resolved from the calendar. The hero-resolver maps the current Pacific-Time date to a season phase. Fourteen are real windows on the league year; the SeasonPhase union below adds two more — a 24-hour trade-deadline override and an offseason-fallback catch-all for any gap (16 states in all). During the two live phases the resolver drops into a second axis — six daily slots keyed on day-of-week and game windows — so a Monday standings hero gives way to a Tuesday recap gives way to a waiver-wire push. A priority ladder (P0++ → P5) breaks ties.

01
championship
Week 17 Thu → Mon night final
02
champion-crowned
Championship decided → +7 days
03
tag-window
After champion crowned → Feb 14
04
tagged-showcase
Feb 15 → auction hero start
05
auction-preview
Mon before 3rd Thu Mar → Thu 7am PT
06
auction-live
3rd Thu Mar 7am PT → +10 days
07
draft-countdown
Auction end → Draft hero (scouting lull)
08
draft-announced
Mon after NFL Draft → rookie draft
09
draft-live
Rookie draft start → draft completes
10
udfa-window
Draft completes → +7 days
11
cut-watch
Jun 1 → 3rd Sun of Aug
12
preseason-countdown
FA close → NFL kickoff
13
regular-season
NFL kickoff → end of Week 14
14
trade-deadline
Nov 13 (24h override)
15
playoffs
Week 15 → Week 16
16
offseason-fallback
Any gap not covered above

Phases with a shipped composite hero today

Daily slots · regular-season + playoffs
live-scoringTNF, Sunday, MNF game windows
standingsMonday pre-game
recapTuesday AM
waiver-wireTuesday PM → Wednesday 8pm
articleSchefter articles (pickups, preview)
game-day-previewSaturday, Sunday pre-game
08

What's Shipped

Five composite heroes live today; each owns its gradient, wordmark, glow, and casting

FeatureCompositeHero
screenshot / castFeatured

The What’s New hero. The feature’s own screenshot in a browser frame; the entry’s featured player composites over the gradient when one is named. Can force the dark treatment when the dark card is the story.

AuctionCompositeHero
castTopFreeAgent / castClosingAuction

Amber money accent. Best-available-by-ADP in preview; once bids are live, it flips to the player whose clock runs out first and adds a high-bid line.

CutWatchCompositeHero
castRosterModel

The first personalized hero — your own bubble player, “You +N” chip. Blue planning tier flips red inside the final 30 days.

PreseasonCompositeHero
castBestScored / castRandomStarter

The best (or a rotating) likely starter in the week’s earliest game — your player if you roster one in the opener.

UdfaCompositeHero
castRookiesOnBoard

Four team-gradient panels of the best rookies still on the board. Casts eight, renders the four whose photos actually load — a 404 never costs a slot.

One shell, seven heroes
CompositeHero · CompositePanelBoard

All of the above now render through two shared components. Seven forks became one spotlight shell and one panel board — every dimension that differed is a custom property, every palette a named accent. 3,028 lines became 800, verified pixel-identical.

Honest scope
not composites (yet)

The playoff hero is a bracket-summary table, not a player composite. Feed-card composites were built and deliberately reverted.

The Bottom Line

The interesting part isn’t the gradient — it’s that a licensing constraint turned into a rule (“every hero means something”), the rule turned into eight small deterministic pickers, and those plug into a calendar-driven state machine that already knew what day it was. Personalization fell out for free, because the casting functions already took a franchise id.

It renders on the server, stays stable for a Pacific-Time day, degrades gracefully when a photo 404s, and looks right in light and dark. No stock photos. No licensed action shots. Just the right player, rendered the right way, every day.

Key files
  • src/utils/hero-casting.ts
  • src/utils/nfl-team-colors.ts
  • src/utils/hero-resolver.ts · src/types/hero-state.ts
  • src/components/shared/CompositeHero.astro
  • docs/…/features/player-composites.md