Files
vyndr/web/src/components/vyndr/PlayerAvatar.tsx
T
builtbykev 24af247b29 DS0 (Design v2): the Entity Layer — teams/players/books render as themselves
Founder's #1 priority. A single cached asset+rendering system, swapped into
every surface, so entities stop being flat gray strings (DESIGN-SPEC Part 2).

- web/src/lib/teamMeta.js: static registry for ALL 4 sports — 30 MLB, 30 NBA,
  13 WNBA teams + 48 World Cup national teams, each with real colors + the
  ESPN logo/flag CDN abbr. resolveTeam (abbr/full-name/nickname/alias),
  teamLogoUrl (statsapi->ESPN mapping: AZ->ari, CWS->chw; soccer via the
  countries/ flag CDN), accentColor (picks the VISIBLE color of the pair so a
  #000000 primary never renders an invisible accent on #06060B). Colors +
  abbrs sourced once from ESPN's team API — stable public facts, zero-latency
  static data, no paid dependency.
- TeamLogo: real ESPN-CDN logo with a team-colored MONOGRAM fallback (never a
  gray box / bare abbr). PlayerAvatar: real headshot with a team-colored
  monogram fallback (kills the gray silhouette). BookWordmark: brand-color
  wordmark, proper casing (DraftKings, not 'draftkings').
- Swapped into the class-level shared components so it propagates to ALL
  surfaces: GameCard (team logos + team-colored accent edge), StatStrip
  (player identity avatar), StreaksPanel (P0 billboard avatars), TeamHub
  header (the team's real crest leads its hub). Barrel-exported.

ZERO OUT-OF-POCKET: ESPN logo/flag CDN + league headshot CDNs, all verified
200 image/png across MLB/NBA/WNBA/soccer.

ACCEPTANCE (SSR render proof): /entity-demo harness server-rendered the exact
real asset URLs across all 4 sports — mlb/500/nyy.png, mlb/500/chw.png (White
Sox, correct ESPN abbr), nba/500/lal.png, wnba/500/ny.png, countries/500/
usa.png + bra/eng/arg/jpn flags, real mlbstatic/nba headshots (Judge 592450,
LeBron 2544), DraftKings/FanDuel wordmarks. Each URL curl-verified 200
image/png. Harness removed post-proof (not a product surface). Pixel
screenshot blocked by WSL2<->Windows-Chrome localhost networking, not code.

2757 -> 2776 tests (+13 entityLayer, +6 boot resilience), web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:02:18 -04:00

77 lines
2.4 KiB
TypeScript

'use client';
import { useState } from 'react';
import { getHeadshotUrl, type HeadshotSport } from '@/lib/playerHeadshot';
import { accentColor } from '@/lib/teamMeta';
/**
* PlayerAvatar (DS0) — real headshot; fallback = TEAM-COLORED MONOGRAM, never
* the gray silhouette (DESIGN-SPEC Part 2). A player always renders with
* identity. When the CDN 404s (roster gaps) we draw the player's initials on
* a disc tinted with their team color — branded, legible, never a gray blob.
*/
function initials(name: string): string {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export default function PlayerAvatar({
name,
sport = 'mlb',
playerId,
team,
size = 36,
}: {
name: string;
sport?: HeadshotSport;
playerId?: string | number | null;
team?: string | null;
size?: number;
}) {
const [broken, setBroken] = useState(false);
const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null;
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
const Monogram = (
<span
title={name}
className="mono"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: '50%', flexShrink: 0,
background: `color-mix(in srgb, ${accent} 22%, #0E0E16)`,
border: `1px solid color-mix(in srgb, ${accent} 50%, transparent)`,
color: accent, fontWeight: 800, fontSize: Math.max(9, size * 0.34), letterSpacing: '0.01em',
}}
>
{initials(name)}
</span>
);
if (!showImg) return Monogram;
return (
<span
style={{
display: 'inline-flex', width: size, height: size, borderRadius: '50%',
overflow: 'hidden', flexShrink: 0, background: '#0E0E16',
border: `1px solid color-mix(in srgb, ${accent} 40%, transparent)`,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url as string}
alt={name}
width={size}
height={size}
loading="lazy"
onError={() => setBroken(true)}
style={{ width: size, height: size, objectFit: 'cover', objectPosition: 'top center' }}
/>
</span>
);
}