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:
@@ -0,0 +1,74 @@
|
||||
// DS0 (Design v2) — THE ENTITY LAYER. Teams resolve to real logos + real
|
||||
// colors; the accent rule keeps team color VISIBLE on the near-black terminal.
|
||||
|
||||
const { resolveTeam, teamLogoUrl, accentColor, luminance } = require('../../web/src/lib/teamMeta');
|
||||
|
||||
describe('resolveTeam — abbr / full name / nickname / alias', () => {
|
||||
test('canonical statsapi abbr', () => {
|
||||
expect(resolveTeam('NYY', 'mlb').name).toBe('New York Yankees');
|
||||
expect(resolveTeam('MIL', 'mlb').name).toBe('Milwaukee Brewers');
|
||||
});
|
||||
test('full name and nickname', () => {
|
||||
expect(resolveTeam('Milwaukee Brewers', 'mlb').abbr).toBe('MIL');
|
||||
expect(resolveTeam('Pirates', 'mlb').abbr).toBe('PIT');
|
||||
expect(resolveTeam('Red Sox', 'mlb').abbr).toBe('BOS');
|
||||
expect(resolveTeam('White Sox', 'mlb').abbr).toBe('CWS');
|
||||
});
|
||||
test('statsapi↔ESPN abbr aliases (AZ↔ARI, CWS↔CHW)', () => {
|
||||
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
|
||||
expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS');
|
||||
});
|
||||
test('unknown team → null (never a fake)', () => {
|
||||
expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
|
||||
expect(resolveTeam('', 'mlb')).toBeNull();
|
||||
});
|
||||
test('resolves across leagues', () => {
|
||||
expect(resolveTeam('LAL', 'nba').name).toBe('Los Angeles Lakers');
|
||||
expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty');
|
||||
});
|
||||
test('soccer = World Cup national teams, by name + alias', () => {
|
||||
expect(resolveTeam('USA', 'soccer').name).toBe('United States');
|
||||
expect(resolveTeam('United States', 'soccer').abbr).toBe('USA');
|
||||
expect(resolveTeam('Brazil', 'soccer').abbr).toBe('BRA');
|
||||
expect(resolveTeam('South Korea', 'soccer').abbr).toBe('KOR');
|
||||
expect(resolveTeam('Ivory Coast', 'soccer').abbr).toBe('CIV'); // alias
|
||||
});
|
||||
});
|
||||
|
||||
describe('teamLogoUrl — real ESPN CDN paths', () => {
|
||||
test('MLB logo url uses the ESPN logo abbr, not the app abbr', () => {
|
||||
expect(teamLogoUrl('AZ', 'mlb')).toBe('https://a.espncdn.com/i/teamlogos/mlb/500/ari.png');
|
||||
expect(teamLogoUrl('CWS', 'mlb')).toBe('https://a.espncdn.com/i/teamlogos/mlb/500/chw.png');
|
||||
expect(teamLogoUrl('NYY', 'mlb')).toBe('https://a.espncdn.com/i/teamlogos/mlb/500/nyy.png');
|
||||
});
|
||||
test('per-league path', () => {
|
||||
expect(teamLogoUrl('LAL', 'nba')).toContain('/teamlogos/nba/500/lal.png');
|
||||
expect(teamLogoUrl('NY', 'wnba')).toContain('/teamlogos/wnba/500/ny.png');
|
||||
});
|
||||
test('soccer national teams use the flag CDN (countries/ + numeric edge)', () => {
|
||||
expect(teamLogoUrl('USA', 'soccer')).toBe('https://a.espncdn.com/i/teamlogos/countries/500/usa.png');
|
||||
expect(teamLogoUrl('South Korea', 'soccer')).toContain('/countries/500/kors.png');
|
||||
expect(teamLogoUrl('Curaçao', 'soccer')).toBe('https://a.espncdn.com/i/teamlogos/soccer/500/11678.png');
|
||||
});
|
||||
test('unknown → null', () => {
|
||||
expect(teamLogoUrl('ZZZ', 'mlb')).toBeNull();
|
||||
expect(teamLogoUrl('Narnia', 'soccer')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accentColor — VISIBLE on #06060B (the near-black bug guard)', () => {
|
||||
test('picks the more luminous of primary/secondary', () => {
|
||||
// White Sox: primary silver (#c4ced4) vs black — must pick silver.
|
||||
expect(accentColor('CWS', 'mlb').toLowerCase()).toBe('#c4ced4');
|
||||
// Pirates: black primary + gold secondary — must pick gold.
|
||||
expect(accentColor('PIT', 'mlb').toLowerCase()).toBe('#fdb827');
|
||||
});
|
||||
test('both-dark team never returns an invisible accent', () => {
|
||||
const acc = accentColor('SF', 'mlb'); // black + orange → orange, visible
|
||||
expect(luminance(acc)).toBeGreaterThan(40);
|
||||
});
|
||||
test('luminance ranks black low, white high', () => {
|
||||
expect(luminance('#000000')).toBeLessThan(10);
|
||||
expect(luminance('#ffffff')).toBeGreaterThan(240);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import TeamLogo from '@/components/vyndr/TeamLogo';
|
||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||
@@ -105,8 +106,9 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
|
||||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '20px 16px 120px' }}>
|
||||
<a href="/dashboard" className="mono" style={{ fontSize: 12, color: 'var(--text-1)', textDecoration: 'none' }}>← Back to Slate</a>
|
||||
|
||||
{/* Header */}
|
||||
{/* Header — DS0: the team's real crest leads the hub. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, margin: '14px 0 22px', flexWrap: 'wrap' }}>
|
||||
<TeamLogo team={data.team.abbr} sport={data.team.sport} size={44} title={data.team.name} />
|
||||
<SportBadge sport={data.team.sport} />
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800, letterSpacing: '-0.015em' }}>{data.team.name}</h1>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{data.team.abbr}</span>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/* ============================================================
|
||||
DS0 (Design v2) — THE ENTITY LAYER: team logos + real colors.
|
||||
|
||||
Teams render as THEMSELVES (DESIGN-SPEC Part 2 / Part 0 law #4): real
|
||||
logo + team-colored accent, never a bare "MIL @ PIT" string.
|
||||
|
||||
Colors + abbrs are stable public facts (like teams.js's names), so a
|
||||
static registry is honest and zero-latency — no runtime fetch, no paid
|
||||
dependency. Colors sourced once from ESPN's team API; logos from ESPN's
|
||||
free CDN (verified 200 image/png). CommonJS so Jest requires it and the
|
||||
.tsx components import it (allowJs).
|
||||
|
||||
Each entry: { name, espn (logo-CDN abbr), primary, secondary }. Keyed by
|
||||
the app/statsapi abbr; resolveTeam() also matches full names + nicknames.
|
||||
The ESPN "primary" color is often near-black (dark-theme invisible), so
|
||||
accentColor() picks the more VISIBLE of the two against #06060B.
|
||||
============================================================ */
|
||||
|
||||
// abbr → meta. MLB abbrs match statsapi; where ESPN's logo abbr differs
|
||||
// (AZ→ari, CWS→chw) the `espn` field carries the CDN spelling.
|
||||
const MLB = {
|
||||
AZ: { name: 'Arizona Diamondbacks', espn: 'ari', primary: '#aa182c', secondary: '#e3d4ad' },
|
||||
ATH: { name: 'Athletics', espn: 'oak', primary: '#003831', secondary: '#efb21e' },
|
||||
ATL: { name: 'Atlanta Braves', espn: 'atl', primary: '#ba0c2f', secondary: '#13274f' },
|
||||
BAL: { name: 'Baltimore Orioles', espn: 'bal', primary: '#df4601', secondary: '#000000' },
|
||||
BOS: { name: 'Boston Red Sox', espn: 'bos', primary: '#bd3039', secondary: '#0d2b56' },
|
||||
CHC: { name: 'Chicago Cubs', espn: 'chc', primary: '#0e3386', secondary: '#cc3433' },
|
||||
CWS: { name: 'Chicago White Sox', espn: 'chw', primary: '#c4ced4', secondary: '#000000' },
|
||||
CIN: { name: 'Cincinnati Reds', espn: 'cin', primary: '#c6011f', secondary: '#000000' },
|
||||
CLE: { name: 'Cleveland Guardians', espn: 'cle', primary: '#e31937', secondary: '#002b5c' },
|
||||
COL: { name: 'Colorado Rockies', espn: 'col', primary: '#8e6cae', secondary: '#c4ced4' },
|
||||
DET: { name: 'Detroit Tigers', espn: 'det', primary: '#0a2240', secondary: '#ff4713' },
|
||||
HOU: { name: 'Houston Astros', espn: 'hou', primary: '#eb6e1f', secondary: '#002d62' },
|
||||
KC: { name: 'Kansas City Royals', espn: 'kc', primary: '#7ab2dd', secondary: '#004687' },
|
||||
LAA: { name: 'Los Angeles Angels', espn: 'laa', primary: '#ba0021', secondary: '#c4ced4' },
|
||||
LAD: { name: 'Los Angeles Dodgers', espn: 'lad', primary: '#005a9c', secondary: '#ef3e42' },
|
||||
MIA: { name: 'Miami Marlins', espn: 'mia', primary: '#00a3e0', secondary: '#ef3340' },
|
||||
MIL: { name: 'Milwaukee Brewers', espn: 'mil', primary: '#ffc72c', secondary: '#13294b' },
|
||||
MIN: { name: 'Minnesota Twins', espn: 'min', primary: '#e20e32', secondary: '#002b5c' },
|
||||
NYM: { name: 'New York Mets', espn: 'nym', primary: '#ff5910', secondary: '#002d72' },
|
||||
NYY: { name: 'New York Yankees', espn: 'nyy', primary: '#c4ced4', secondary: '#132448' },
|
||||
PHI: { name: 'Philadelphia Phillies',espn: 'phi', primary: '#e81828', secondary: '#284898' },
|
||||
PIT: { name: 'Pittsburgh Pirates', espn: 'pit', primary: '#fdb827', secondary: '#000000' },
|
||||
SD: { name: 'San Diego Padres', espn: 'sd', primary: '#ffc425', secondary: '#2f241d' },
|
||||
SF: { name: 'San Francisco Giants', espn: 'sf', primary: '#fd5a1e', secondary: '#000000' },
|
||||
SEA: { name: 'Seattle Mariners', espn: 'sea', primary: '#0c8f8f', secondary: '#0c2c56' },
|
||||
STL: { name: 'St. Louis Cardinals', espn: 'stl', primary: '#be0a14', secondary: '#0a2252' },
|
||||
TB: { name: 'Tampa Bay Rays', espn: 'tb', primary: '#8fbce6', secondary: '#092c5c' },
|
||||
TEX: { name: 'Texas Rangers', espn: 'tex', primary: '#c0111f', secondary: '#003278' },
|
||||
TOR: { name: 'Toronto Blue Jays', espn: 'tor', primary: '#1d78cf', secondary: '#134a8e' },
|
||||
WSH: { name: 'Washington Nationals', espn: 'wsh', primary: '#ab0003', secondary: '#11225b' },
|
||||
};
|
||||
|
||||
const NBA = {
|
||||
ATL: { name: 'Atlanta Hawks', espn: 'atl', primary: '#e03a3e', secondary: '#fdb927' },
|
||||
BOS: { name: 'Boston Celtics', espn: 'bos', primary: '#008348', secondary: '#ffffff' },
|
||||
BKN: { name: 'Brooklyn Nets', espn: 'bkn', primary: '#c4ced4', secondary: '#000000' },
|
||||
CHA: { name: 'Charlotte Hornets', espn: 'cha', primary: '#00a3af', secondary: '#1d1160' },
|
||||
CHI: { name: 'Chicago Bulls', espn: 'chi', primary: '#ce1141', secondary: '#000000' },
|
||||
CLE: { name: 'Cleveland Cavaliers', espn: 'cle', primary: '#bc945c', secondary: '#860038' },
|
||||
DAL: { name: 'Dallas Mavericks', espn: 'dal', primary: '#0064b1', secondary: '#bbc4ca' },
|
||||
DEN: { name: 'Denver Nuggets', espn: 'den', primary: '#fec524', secondary: '#0e2240' },
|
||||
DET: { name: 'Detroit Pistons', espn: 'det', primary: '#1d428a', secondary: '#c8102e' },
|
||||
GSW: { name: 'Golden State Warriors', espn: 'gs', primary: '#fdb927', secondary: '#1d428a' },
|
||||
HOU: { name: 'Houston Rockets', espn: 'hou', primary: '#ce1141', secondary: '#c4ced4' },
|
||||
IND: { name: 'Indiana Pacers', espn: 'ind', primary: '#ffd520', secondary: '#0c2340' },
|
||||
LAC: { name: 'LA Clippers', espn: 'lac', primary: '#c8102e', secondary: '#1d428a' },
|
||||
LAL: { name: 'Los Angeles Lakers', espn: 'lal', primary: '#fdb927', secondary: '#552583' },
|
||||
MEM: { name: 'Memphis Grizzlies', espn: 'mem', primary: '#5d76a9', secondary: '#12173f' },
|
||||
MIA: { name: 'Miami Heat', espn: 'mia', primary: '#98002e', secondary: '#f9a01b' },
|
||||
MIL: { name: 'Milwaukee Bucks', espn: 'mil', primary: '#00471b', secondary: '#eee1c6' },
|
||||
MIN: { name: 'Minnesota Timberwolves', espn: 'min', primary: '#79bc43', secondary: '#266092' },
|
||||
NOP: { name: 'New Orleans Pelicans', espn: 'no', primary: '#b4975a', secondary: '#0a2240' },
|
||||
NYK: { name: 'New York Knicks', espn: 'ny', primary: '#f58426', secondary: '#1d428a' },
|
||||
OKC: { name: 'Oklahoma City Thunder', espn: 'okc', primary: '#007ac1', secondary: '#ef3b24' },
|
||||
ORL: { name: 'Orlando Magic', espn: 'orl', primary: '#0077c0', secondary: '#c4ced4' },
|
||||
PHI: { name: 'Philadelphia 76ers', espn: 'phi', primary: '#1d428a', secondary: '#e01234' },
|
||||
PHX: { name: 'Phoenix Suns', espn: 'phx', primary: '#e56020', secondary: '#1d1160' },
|
||||
POR: { name: 'Portland Trail Blazers', espn: 'por', primary: '#e03a3e', secondary: '#c4ced4' },
|
||||
SAC: { name: 'Sacramento Kings', espn: 'sac', primary: '#5a2d81', secondary: '#63727a' },
|
||||
SAS: { name: 'San Antonio Spurs', espn: 'sa', primary: '#c4ced4', secondary: '#000000' },
|
||||
TOR: { name: 'Toronto Raptors', espn: 'tor', primary: '#ce1141', secondary: '#000000' },
|
||||
UTA: { name: 'Utah Jazz', espn: 'utah', primary: '#79a3dc', secondary: '#4e008e' },
|
||||
WAS: { name: 'Washington Wizards', espn: 'wsh', primary: '#e31837', secondary: '#002b5c' },
|
||||
};
|
||||
|
||||
const WNBA = {
|
||||
ATL: { name: 'Atlanta Dream', espn: 'atl', primary: '#e31837', secondary: '#5091cc' },
|
||||
CHI: { name: 'Chicago Sky', espn: 'chi', primary: '#5091cd', secondary: '#ffd520' },
|
||||
CON: { name: 'Connecticut Sun', espn: 'conn', primary: '#f05023', secondary: '#0a2240' },
|
||||
DAL: { name: 'Dallas Wings', espn: 'dal', primary: '#c4d600', secondary: '#002b5c' },
|
||||
GSV: { name: 'Golden State Valkyries', espn: 'gs', primary: '#b38fcf', secondary: '#000000' },
|
||||
IND: { name: 'Indiana Fever', espn: 'ind', primary: '#e03a3e', secondary: '#002d62' },
|
||||
LV: { name: 'Las Vegas Aces', espn: 'lv', primary: '#a7a8aa', secondary: '#000000' },
|
||||
LA: { name: 'Los Angeles Sparks', espn: 'la', primary: '#fdb927', secondary: '#552583' },
|
||||
MIN: { name: 'Minnesota Lynx', espn: 'min', primary: '#79bc43', secondary: '#266092' },
|
||||
NY: { name: 'New York Liberty', espn: 'ny', primary: '#86cebc', secondary: '#000000' },
|
||||
PHX: { name: 'Phoenix Mercury', espn: 'phx', primary: '#fa4b0a', secondary: '#3c286e' },
|
||||
SEA: { name: 'Seattle Storm', espn: 'sea', primary: '#2c5235', secondary: '#fee11a' },
|
||||
WAS: { name: 'Washington Mystics', espn: 'wsh', primary: '#e03a3e', secondary: '#002b5c' },
|
||||
};
|
||||
|
||||
// Soccer = World Cup national teams (fifa.world). Keyed by the ESPN country
|
||||
// code (uppercased); the flag CDN is a different path (countries/, not
|
||||
// teamlogos/soccer). Colors + codes from ESPN's fifa.world API. National
|
||||
// teams ARE their flag — that's the crest.
|
||||
const SOCCER = {
|
||||
ALG: { name: 'Algeria', espn: 'alg', primary: '#4f9a44', secondary: '#ffffff' },
|
||||
ARG: { name: 'Argentina', espn: 'arg', primary: '#74acdf', secondary: '#173e69' },
|
||||
AUS: { name: 'Australia', espn: 'aus', primary: '#ffcd00', secondary: '#00843d' },
|
||||
AUT: { name: 'Austria', espn: 'aut', primary: '#d72b2c', secondary: '#000000' },
|
||||
BEL: { name: 'Belgium', espn: 'bel', primary: '#e30613', secondary: '#6ecff6' },
|
||||
BIH: { name: 'Bosnia-Herzegovina', espn: 'bih', primary: '#112855', secondary: '#ffce00' },
|
||||
BRA: { name: 'Brazil', espn: 'bra', primary: '#fee000', secondary: '#193375' },
|
||||
CAN: { name: 'Canada', espn: 'can', primary: '#ed2224', secondary: '#ffffff' },
|
||||
CPV: { name: 'Cape Verde', espn: 'cpv', primary: '#0537e4', secondary: '#ef3340' },
|
||||
COL: { name: 'Colombia', espn: 'col', primary: '#fbd632', secondary: '#21418c' },
|
||||
RDC: { name: 'Congo DR', espn: 'rdc', primary: '#418fde', secondary: '#c60000' },
|
||||
CRO: { name: 'Croatia', espn: 'cro', primary: '#ff0000', secondary: '#0c2fff' },
|
||||
CUW: { name: 'Curaçao', espn: '11678', primary: '#0537e4', secondary: '#ffce00' },
|
||||
CZE: { name: 'Czechia', espn: 'cze', primary: '#d7141a', secondary: '#11457e' },
|
||||
ECU: { name: 'Ecuador', espn: 'ecu', primary: '#ffdd00', secondary: '#034ea2' },
|
||||
EGY: { name: 'Egypt', espn: 'egy', primary: '#d20300', secondary: '#ffffff' },
|
||||
ENG: { name: 'England', espn: 'eng', primary: '#ea1f29', secondary: '#ffffff' },
|
||||
FRA: { name: 'France', espn: 'fra', primary: '#3a6dd8', secondary: '#ef4135' },
|
||||
GER: { name: 'Germany', espn: 'ger', primary: '#ffce00', secondary: '#dd0000' },
|
||||
GHA: { name: 'Ghana', espn: 'gha', primary: '#fbd632', secondary: '#006b3f' },
|
||||
HAI: { name: 'Haiti', espn: 'hai', primary: '#0033a0', secondary: '#d21034' },
|
||||
IRN: { name: 'Iran', espn: 'irn', primary: '#da0000', secondary: '#239f40' },
|
||||
IRQ: { name: 'Iraq', espn: 'irq', primary: '#0a4d2e', secondary: '#ce1126' },
|
||||
CIV: { name: 'Ivory Coast', espn: 'civ', primary: '#ff8200', secondary: '#009e60' },
|
||||
JPN: { name: 'Japan', espn: 'jpn', primary: '#3a6dd8', secondary: '#bc002d' },
|
||||
JOR: { name: 'Jordan', espn: 'jor', primary: '#e70000', secondary: '#007a3d' },
|
||||
MEX: { name: 'Mexico', espn: 'mex', primary: '#00843d', secondary: '#ce1126' },
|
||||
MAR: { name: 'Morocco', espn: 'mar', primary: '#df2027', secondary: '#006233' },
|
||||
NED: { name: 'Netherlands', espn: 'ned', primary: '#fb5d00', secondary: '#21468b' },
|
||||
NZL: { name: 'New Zealand', espn: 'nzl', primary: '#c8c8c8', secondary: '#000000' },
|
||||
NOR: { name: 'Norway', espn: 'nor', primary: '#c8102e', secondary: '#00205b' },
|
||||
PAN: { name: 'Panama', espn: 'pan', primary: '#d21034', secondary: '#005293' },
|
||||
PAR: { name: 'Paraguay', espn: 'par', primary: '#ea2300', secondary: '#21418c' },
|
||||
POR: { name: 'Portugal', espn: 'por', primary: '#da291c', secondary: '#0d6938' },
|
||||
QAT: { name: 'Qatar', espn: 'qat', primary: '#8a1538', secondary: '#ffffff' },
|
||||
KSA: { name: 'Saudi Arabia', espn: 'ksa', primary: '#0f7a3d', secondary: '#ffffff' },
|
||||
SCO: { name: 'Scotland', espn: 'sco', primary: '#4b6cb7', secondary: '#1a2d69' },
|
||||
SEN: { name: 'Senegal', espn: 'sen', primary: '#00853f', secondary: '#fdef42' },
|
||||
RSA: { name: 'South Africa', espn: 'rsa', primary: '#ffb81c', secondary: '#0c562e' },
|
||||
KOR: { name: 'South Korea', espn: 'kors', primary: '#ce2028', secondary: '#003478' },
|
||||
ESP: { name: 'Spain', espn: 'esp', primary: '#c60b1e', secondary: '#ffc400' },
|
||||
SWE: { name: 'Sweden', espn: 'swe', primary: '#fecb00', secondary: '#006aa7' },
|
||||
SUI: { name: 'Switzerland', espn: 'sui', primary: '#ff0000', secondary: '#ffffff' },
|
||||
TUN: { name: 'Tunisia', espn: 'tun', primary: '#d20300', secondary: '#ffffff' },
|
||||
TUR: { name: 'Türkiye', espn: 'tur', primary: '#ef3340', secondary: '#ffffff' },
|
||||
USA: { name: 'United States', espn: 'usa', primary: '#3a6dd8', secondary: '#d42339' },
|
||||
URU: { name: 'Uruguay', espn: 'uru', primary: '#55b5e5', secondary: '#00205b' },
|
||||
UZB: { name: 'Uzbekistan', espn: 'uzb', primary: '#0072ce', secondary: '#1eb53a' },
|
||||
};
|
||||
|
||||
const LEAGUES = { mlb: MLB, nba: NBA, wnba: WNBA, soccer: SOCCER };
|
||||
|
||||
// Common naming variants the odds feeds use → the canonical soccer key.
|
||||
const SOCCER_ALIAS = {
|
||||
usa: 'USA', unitedstates: 'USA', usmnt: 'USA',
|
||||
southkorea: 'KOR', koreademocraticpeoplesrepublic: 'KOR', kore : 'KOR', korearepublic: 'KOR',
|
||||
ivorycoast: 'CIV', cotedivoire: 'CIV',
|
||||
turkey: 'TUR', turkiye: 'TUR',
|
||||
czechrepublic: 'CZE', czechia: 'CZE',
|
||||
bosnia: 'BIH', bosniaandherzegovina: 'BIH',
|
||||
congodr: 'RDC', drcongo: 'RDC',
|
||||
curacao: 'CUW',
|
||||
};
|
||||
|
||||
// Full-name + nickname → abbr, built once per league for resolveTeam().
|
||||
const NAME_INDEX = {};
|
||||
for (const [sp, table] of Object.entries(LEAGUES)) {
|
||||
NAME_INDEX[sp] = {};
|
||||
for (const [abbr, meta] of Object.entries(table)) {
|
||||
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
NAME_INDEX[sp][norm(meta.name)] = abbr;
|
||||
// nickname = last word ("Yankees", "Red Sox" → "redsox")
|
||||
const parts = meta.name.split(/\s+/);
|
||||
NAME_INDEX[sp][norm(parts[parts.length - 1])] = abbr;
|
||||
// two-word nickname ("Red Sox", "Blue Jays", "White Sox")
|
||||
if (parts.length >= 2) NAME_INDEX[sp][norm(parts.slice(-2).join(''))] = abbr;
|
||||
}
|
||||
}
|
||||
|
||||
const normKey = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
/** Resolve any team key (abbr, full name, nickname) + sport → meta or null. */
|
||||
function resolveTeam(key, sport) {
|
||||
if (!key) return null;
|
||||
const sp = String(sport || 'mlb').toLowerCase();
|
||||
const table = LEAGUES[sp];
|
||||
if (!table) return null;
|
||||
const up = String(key).toUpperCase().trim();
|
||||
if (table[up]) return { abbr: up, sport: sp, ...table[up] };
|
||||
// statsapi/ESPN alias fallbacks for the two MLB mismatches
|
||||
const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' };
|
||||
if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] };
|
||||
if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
|
||||
const a = SOCCER_ALIAS[normKey(key)];
|
||||
return { abbr: a, sport: sp, ...table[a] };
|
||||
}
|
||||
const byName = NAME_INDEX[sp][normKey(key)];
|
||||
if (byName) return { abbr: byName, sport: sp, ...table[byName] };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ESPN free logo CDN url for a team, or null when unresolved. Soccer national
|
||||
* teams use the flag CDN (countries/ for a 3-letter code, soccer/ for the rare
|
||||
* numeric team id); the ball sports use teamlogos/{league}/. */
|
||||
function teamLogoUrl(key, sport) {
|
||||
const m = resolveTeam(key, sport);
|
||||
if (!m) return null;
|
||||
if (m.sport === 'soccer') {
|
||||
return /^\d+$/.test(m.espn)
|
||||
? `https://a.espncdn.com/i/teamlogos/soccer/500/${m.espn}.png`
|
||||
: `https://a.espncdn.com/i/teamlogos/countries/500/${m.espn}.png`;
|
||||
}
|
||||
const path = m.sport === 'wnba' ? 'wnba' : 'nba';
|
||||
return `https://a.espncdn.com/i/teamlogos/${m.sport === 'mlb' ? 'mlb' : path}/500/${m.espn}.png`;
|
||||
}
|
||||
|
||||
// Perceived luminance of a #rrggbb (0–255). Used to keep team accents
|
||||
// VISIBLE on the near-black terminal bg — a #000000 primary is useless.
|
||||
function luminance(hex) {
|
||||
const h = String(hex || '').replace('#', '');
|
||||
if (h.length !== 6) return 0;
|
||||
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/** The team color to use for an accent/monogram against the dark theme:
|
||||
* the more visible of primary/secondary. Falls back to a neutral when
|
||||
* both are near-black (never returns an invisible accent). */
|
||||
function accentColor(key, sport) {
|
||||
const m = resolveTeam(key, sport);
|
||||
if (!m) return null;
|
||||
const lp = luminance(m.primary), ls = luminance(m.secondary);
|
||||
const best = lp >= ls ? m.primary : m.secondary;
|
||||
return luminance(best) < 40 ? '#8A8A9A' : best; // both dark → neutral chrome
|
||||
}
|
||||
|
||||
module.exports = { resolveTeam, teamLogoUrl, accentColor, luminance, LEAGUES, __tables: { MLB, NBA, WNBA } };
|
||||
Reference in New Issue
Block a user