diff --git a/tests/unit/bookWordmark.test.js b/tests/unit/bookWordmark.test.js
new file mode 100644
index 0000000..0c6b9e4
--- /dev/null
+++ b/tests/unit/bookWordmark.test.js
@@ -0,0 +1,114 @@
+// Wave 2B (data train) — sportsbook wordmarks. Every book the odds feed emits
+// resolves to a real brand (name + non-gray color); the ~8 major books render a
+// bundled local wordmark SVG; the ledger row renders BookWordmark, never bare
+// lowercase `row.book`. The SVGs are self-authored styled text wordmarks (not
+// copied trademarked logos), swappable for official press-kit art later.
+
+const fs = require('fs');
+const path = require('path');
+const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books');
+
+// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline.
+const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle'];
+const DEFAULT_FG = '#B8BCC8';
+
+const REPO = path.resolve(__dirname, '../..');
+const read = (p) => fs.readFileSync(path.join(REPO, p), 'utf8');
+
+describe('bookInfo — every ALLOWED_BOOK is a real brand (no neutral-gray fall)', () => {
+ test.each(ALLOWED_BOOKS)('%s resolves to a real brand name + color', (key) => {
+ const b = bookInfo(key);
+ // name must be a real brand, not the raw uppercased key echoed back
+ expect(b.name).toBeTruthy();
+ expect(b.name.toUpperCase()).not.toBe(key.toUpperCase());
+ // color must not be the neutral-gray default
+ expect(b.fg).toBeTruthy();
+ expect(b.fg.toLowerCase()).not.toBe(DEFAULT_FG.toLowerCase());
+ // never a bare lowercase feed key in the display name
+ expect(b.name).not.toBe(key);
+ });
+
+ test('the 6 previously-missing keys now resolve (were gray)', () => {
+ expect(bookInfo('fanatics').name).toBe('Fanatics');
+ expect(bookInfo('bet365').name).toBe('bet365');
+ expect(bookInfo('hardrockbet').name).toBe('Hard Rock');
+ expect(bookInfo('betrivers').name).toBe('BetRivers');
+ expect(bookInfo('pointsbet').name).toBe('PointsBet');
+ expect(bookInfo('pinnacle').name).toBe('Pinnacle');
+ });
+
+ test('case-insensitive: DraftKings / DK / draftkings all resolve the same', () => {
+ expect(bookInfo('DraftKings').name).toBe('DraftKings');
+ expect(bookInfo('DK').name).toBe('DraftKings');
+ expect(bookInfo('draftkings').name).toBe('DraftKings');
+ });
+});
+
+describe('bundled book wordmark SVGs', () => {
+ const EXPECTED = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers'];
+
+ test('BUNDLED_BOOK_SVGS is exactly the 8 major books', () => {
+ expect([...BUNDLED_BOOK_SVGS].sort()).toEqual([...EXPECTED].sort());
+ });
+
+ test.each(EXPECTED)('web/public/books/%s.svg exists and is a real
- {row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
+ {row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
{row.model_value != null && · MODEL {row.model_value}}
diff --git a/web/src/app/u/[handle]/PublicProfile.tsx b/web/src/app/u/[handle]/PublicProfile.tsx
index 03811dd..ed29f34 100644
--- a/web/src/app/u/[handle]/PublicProfile.tsx
+++ b/web/src/app/u/[handle]/PublicProfile.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
+import BookWordmark from '@/components/vyndr/BookWordmark';
/**
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
@@ -272,7 +273,7 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
diff --git a/web/src/components/vyndr/BookWordmark.tsx b/web/src/components/vyndr/BookWordmark.tsx
index 04628b2..7c66c9f 100644
--- a/web/src/components/vyndr/BookWordmark.tsx
+++ b/web/src/components/vyndr/BookWordmark.tsx
@@ -1,10 +1,16 @@
-import { bookInfo } from '@/lib/books';
+import { bookInfo, bookSlug, hasBookSvg } 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.
+ * BookWordmark (DS0 · Wave 2B) — a sportsbook renders as its brand. When a
+ * bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8
+ * major books) it renders that; otherwise it falls back to the real book NAME
+ * in the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
+ * lowercase "draftkings" string (DESIGN-SPEC Part 2).
+ *
+ * The bundled SVGs are self-authored styled text wordmarks — not copied
+ * trademarked logo glyphs — so the founder can drop official press-kit art into
+ * the same paths later with zero code change. The files are local + always
+ * present, so no broken-image state is possible. `best` gives the signal ring.
*/
export default function BookWordmark({
book,
@@ -16,6 +22,8 @@ export default function BookWordmark({
size?: number;
}) {
const b = bookInfo(book);
+ const slug = bookSlug(book);
+ const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null;
return (
{best && }
- {b.name}
+ {svg ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+ b.name
+ )}
);
}
diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx
index ef68c2a..53d8611 100644
--- a/web/src/components/vyndr/GameCard.tsx
+++ b/web/src/components/vyndr/GameCard.tsx
@@ -9,7 +9,7 @@ import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@
import TeamLogo from '@/components/vyndr/TeamLogo';
import { accentColor } from '@/lib/teamMeta';
import { playerHref } from '@/lib/playerHref';
-import { isPreferredBook } from '@/lib/books';
+import { isPreferredBook, bookInfo } from '@/lib/books';
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
import { nextRunLabelET } from '@/lib/pipelineSchedule';
import { useParlay, legKey } from '@/contexts/ParlayContext';
@@ -301,7 +301,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
O/U
{g.lines.map((ln, i) => (
-
{ln.book}
+
{bookInfo(ln.book).name}
diff --git a/web/src/lib/books.js b/web/src/lib/books.js
index 48a42c7..4e5f2d1 100644
--- a/web/src/lib/books.js
+++ b/web/src/lib/books.js
@@ -1,29 +1,69 @@
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
- BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */
+ BOOKS table. CommonJS so it's testable + importable from the .tsx chip.
+
+ Wave 2B (data train) — the map now covers EVERY key the odds feed emits
+ (`oddsNormalizer.ALLOWED_BOOKS`): draftkings, fanduel, betmgm, caesars,
+ fanatics, bet365, hardrockbet, pointsbet, betrivers, pinnacle. No live book
+ falls to the neutral-gray default. Entries carry a `slug` = the canonical
+ lowercase book key; `bookSlug()` resolves any input (id/code/name) to it, and
+ BUNDLED_BOOK_SVGS names the 8 books that have a local wordmark SVG under
+ `web/public/books/{slug}.svg` (self-authored styled wordmarks, swappable for
+ official press-kit art without a code change). */
const BOOKS = {
- DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
- DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
- FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
- FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
- MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
- BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
- CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
- CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
+ DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
+ DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
+ FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
+ FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
+ MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
+ BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
+ CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
+ CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
- BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' },
+ // BetRivers is a blue book (its logo is blue "BetRivers"), not purple.
+ BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
+ BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
- FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
- B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
- HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
+ FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
+ FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
+ B365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
+ BET365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
+ HR: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
+ HARDROCKBET: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
+ POINTSBET: { name: 'PointsBet', mono: 'PTS', slug: 'pointsbet', bg: '#1F0808', fg: '#E4344A', bd: '#E4344A55' },
+ PINNACLE: { name: 'Pinnacle', mono: 'PIN', slug: 'pinnacle', bg: '#180C0E', fg: '#C8434F', bd: '#C8434F55' },
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
};
+// The 8 books with a bundled local wordmark SVG (web/public/books/{slug}.svg).
+// Self-authored styled text wordmarks — NOT copied trademarked logo glyphs.
+const BUNDLED_BOOK_SVGS = new Set([
+ 'draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers',
+]);
+
+const DEFAULT_FG = '#B8BCC8';
+
function bookInfo(book) {
const key = String(book == null ? '' : book).toUpperCase();
- return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
+ return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: DEFAULT_FG, bd: '#23232F' };
+}
+
+/** Canonical lowercase book slug for any input (id/code/name), or null when the
+ * book isn't in the registry. Used to look up the bundled wordmark SVG. */
+function bookSlug(book) {
+ const info = bookInfo(book);
+ if (info.slug) return info.slug;
+ // Unknown-but-nameable: derive a slug from the resolved name (still not the
+ // raw lowercase feed key). Only bundled slugs matter for the SVG lookup.
+ return null;
+}
+
+/** Does `book` have a bundled local wordmark SVG? */
+function hasBookSvg(book) {
+ const slug = bookSlug(book);
+ return slug != null && BUNDLED_BOOK_SVGS.has(slug);
}
/** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings"
@@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) {
return preferred.some((p) => bookKey(p) === k);
}
-module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook };
+module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook };
diff --git a/web/src/lib/teamMeta.js b/web/src/lib/teamMeta.js
index bc230ed..27cd0bb 100644
--- a/web/src/lib/teamMeta.js
+++ b/web/src/lib/teamMeta.js
@@ -195,7 +195,16 @@ function resolveTeam(key, sport) {
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' };
+ // Global alias table. resolveTeam checks the real abbr FIRST, so an entry
+ // only fires for a sport whose table lacks that key — e.g. WSH resolves
+ // directly to the MLB Nationals, but NBA/WNBA (no WSH key) fall through to
+ // WAS (Wizards/Mystics). ESPN-schedule abbrs the ball sports actually emit
+ // (SA/NY/WSH/BRK for NBA, CONN for WNBA) are covered here.
+ 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',
+ SA: 'SAS', NY: 'NYK', WSH: 'WAS', BRK: 'BKN', CONN: 'CON',
+ };
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)];