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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user