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>
This commit is contained in:
Kev
2026-07-12 19:02:18 -04:00
parent b452db727c
commit 24af247b29
10 changed files with 520 additions and 17 deletions
+4 -12
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type Tier } from '@/lib/tierGate';
/**
@@ -73,14 +73,9 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((s) => (
<div key={`${s.player}-${s.type}`} style={rowStyle}>
<img
src={getHeadshotUrl({ sport, playerId: s.playerId })}
alt={s.player}
width={36}
height={36}
style={avatarStyle}
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
/>
{/* DS0 — real headshot / team-colored monogram (kills the gray
silhouette). The STREAKS row is a P0 billboard. */}
<PlayerAvatar name={s.player} sport={sport} playerId={s.playerId} team={s.team} size={38} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>
{s.player}{s.team ? ` · ${s.team}` : ''}
@@ -118,9 +113,6 @@ const rowStyle: React.CSSProperties = {
padding: '8px 10px', borderRadius: 10,
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
};
const avatarStyle: React.CSSProperties = {
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
+35
View File
@@ -0,0 +1,35 @@
import { bookInfo } from '@/lib/books';
/**
* BookWordmark (DS0) — a sportsbook renders as its brand: the real name in
* the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
* lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts
* where the BookChip tile is too heavy. `best` gives it the signal ring.
*/
export default function BookWordmark({
book,
best = false,
size = 12,
}: {
book: string;
best?: boolean;
size?: number;
}) {
const b = bookInfo(book);
return (
<span
className="mono"
title={best ? `Best price · ${b.name}` : b.name}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, whiteSpace: 'nowrap',
fontSize: size, fontWeight: 700, color: b.fg, letterSpacing: '0.01em',
...(best
? { padding: '1px 7px', borderRadius: 5, background: `color-mix(in srgb, ${b.fg} 12%, transparent)`, border: `1px solid color-mix(in srgb, ${b.fg} 45%, transparent)` }
: {}),
}}
>
{best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />}
{b.name}
</span>
);
}
+11 -4
View File
@@ -6,6 +6,8 @@ import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip';
import TeamLogo from '@/components/vyndr/TeamLogo';
import { accentColor } from '@/lib/teamMeta';
import { playerHref } from '@/lib/playerHref';
import { isPreferredBook } from '@/lib/books';
import { useParlay, legKey } from '@/contexts/ParlayContext';
@@ -100,19 +102,21 @@ function collapseStrips(strips: PlayerStrip[]) {
return { sorted, visible, totalReads, truncated: visible.length < sorted.length };
}
/** Clickable team abbreviation → /team/:abbr (Session 51). Stops propagation so
* it doesn't trigger the card's open-game handler; green underline on hover. */
/** Clickable team → /team/:abbr (Session 51). DS0: renders the real LOGO +
* abbr (the entity layer), not a bare string. Stops propagation so it
* doesn't trigger the card's open-game handler. */
function TeamLink({ abbr, sport }: { abbr: string; sport: string }) {
if (!abbr) return <span></span>;
return (
<a
href={`/team/${encodeURIComponent(abbr)}?sport=${encodeURIComponent(sport || 'mlb')}`}
onClick={(e) => e.stopPropagation()}
style={{ color: '#fff', textDecoration: 'none', borderBottom: '1px solid transparent' }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: '#fff', textDecoration: 'none', borderBottom: '1px solid transparent' }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--g-a)'; e.currentTarget.style.borderBottomColor = 'var(--g-a)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderBottomColor = 'transparent'; }}
title={`${abbr} team hub`}
>
<TeamLogo team={abbr} sport={sport} size={22} />
{abbr}
</a>
);
@@ -206,8 +210,11 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
},
isLegActive: (p: StripProp) => hasLeg(legKey(toLeg(ps, p))),
});
// DS0 — a team-colored accent edge (home team) so the card reads as THIS
// matchup, not a generic panel. Visible-color rule keeps it off near-black.
const homeAccent = accentColor(g.home.abbr, g.sport) || 'var(--border)';
return (
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderLeft: `3px solid color-mix(in srgb, ${homeAccent} 65%, transparent)`, borderRadius: 10, overflow: 'hidden' }}>
{/* HEADER */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '13px 16px 11px' }}>
<div onClick={() => onOpen && onOpen(g.id)} title="Open game detail" style={{ display: 'flex', alignItems: 'center', gap: 11, minWidth: 0, cursor: onOpen ? 'pointer' : 'default' }}>
+76
View File
@@ -0,0 +1,76 @@
'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>
);
}
+4
View File
@@ -1,6 +1,7 @@
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import Sparkline from '@/components/vyndr/Sparkline';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { nextRunLabelET } from '@/lib/pipelineSchedule';
// A1 S3 — BOOK IT is a real per-book deep link now (organic until the
// affiliate config flips a book on). rel MUST stay BOOK_LINK_REL.
@@ -375,6 +376,9 @@ export default function StatStrip({
return (
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 9 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{/* DS0 — player identity block: real headshot / team-colored monogram
(never the gray silhouette). Team drives the accent color. */}
<PlayerAvatar name={player} sport={sport} team={team} size={30} />
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { useState } from 'react';
import { teamLogoUrl, accentColor, resolveTeam } from '@/lib/teamMeta';
/**
* TeamLogo (DS0) — a team renders as ITSELF: real ESPN-CDN logo, with a
* team-colored monogram fallback (NEVER a gray box or bare abbr). The whole
* entity layer's job is that "MIL @ PIT" stops being flat text.
*
* On a 404 / unknown team we draw the abbr on a disc in the team's accent
* color — still branded, still legible, never a fallback that reads broken.
*/
export default function TeamLogo({
team,
sport = 'mlb',
size = 28,
title,
}: {
team: string;
sport?: string;
size?: number;
title?: string;
}) {
const [broken, setBroken] = useState(false);
const url = teamLogoUrl(team, sport);
const meta = resolveTeam(team, sport);
const accent = accentColor(team, sport) || 'var(--text-1)';
const label = meta?.abbr || String(team || '').toUpperCase().slice(0, 3);
if (!url || broken) {
// Team-colored monogram disc — branded fallback, never gray.
return (
<span
title={title || meta?.name || label}
className="mono"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: '50%', flexShrink: 0,
background: `color-mix(in srgb, ${accent} 20%, #0E0E16)`,
border: `1px solid color-mix(in srgb, ${accent} 55%, transparent)`,
color: accent, fontWeight: 800, fontSize: Math.max(8, size * 0.36),
letterSpacing: '0.02em',
}}
>
{label.slice(0, 3)}
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={meta?.name || label}
title={title || meta?.name || label}
width={size}
height={size}
loading="lazy"
onError={() => setBroken(true)}
style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0, display: 'block' }}
/>
);
}
+5
View File
@@ -25,6 +25,11 @@ export { default as StatStrip } from './StatStrip';
export type { StatCell, StripProp, StripArchetype } from './StatStrip';
export { default as BookChip } from './BookChip';
/* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */
export { default as TeamLogo } from './TeamLogo';
export { default as PlayerAvatar } from './PlayerAvatar';
export { default as BookWordmark } from './BookWordmark';
/* S6 (A1 board) — global search + row-grammar micro-marks */
export { default as SearchModal } from './SearchModal';
export { DotStrip, LineSparkline } from './StatStrip';