-
{p.name}
- {p.captain && (
-
- C
-
- )}
+
+
+ {p.name}
+ {p.no !== null && {p.no}}
+ {p.captain && (
+
+ C
+
+ )}
+
+
+ {age !== null && (
+
+ {t('ageN', { n: age })}
+
+ )}
+ {showStats && (
+
+ {statSeg(p.wcApps ?? 0, p.wcGoals ?? 0, t('appsWc'), t('goalsWc'))}
+ ·
+ {statSeg(
+ (p.caps ?? 0) + (p.wcApps ?? 0),
+ (p.goals ?? 0) + (p.wcGoals ?? 0),
+ t('appsCareer'),
+ t('goalsCareer'),
+ )}
+
+ )}
+ {((p.wcYellow ?? 0) > 0 || (p.wcRed ?? 0) > 0) && (
+
+ {(p.wcYellow ?? 0) > 0 && (
+
+ 🟨 {p.wcYellow}
+
+ )}
+ {(p.wcYellow ?? 0) > 0 && (p.wcRed ?? 0) > 0 && ·}
+ {(p.wcRed ?? 0) > 0 && (
+
+ 🟥 {p.wcRed}
+
+ )}
+
+ )}
+ {(p.club || p.wiki) && (
+
+ {p.club && (
+ <>
+ {clubIso &&
}
+ {clubUrl ? (
+
+ {p.club}
+
+ ) : (
+
{p.club}
+ )}
+ >
+ )}
+ {p.wiki &&
}
+
+ )}
+
-
- {(age !== null || p.wiki) && (
-
- {age !== null && {t('ageN', { n: age })}}
- {p.wiki && }
-
- )}
- {showStats && (
-
- {statSeg(p.wcApps ?? 0, p.wcGoals ?? 0, t('appsWc'), t('goalsWc'))}
- ·
- {statSeg(
- (p.caps ?? 0) + (p.wcApps ?? 0),
- (p.goals ?? 0) + (p.wcGoals ?? 0),
- t('appsCareer'),
- t('goalsCareer'),
- )}
-
- )}
- {((p.wcYellow ?? 0) > 0 || (p.wcRed ?? 0) > 0) && (
-
- {(p.wcYellow ?? 0) > 0 && (
-
- 🟨 {p.wcYellow}
-
- )}
- {(p.wcYellow ?? 0) > 0 && (p.wcRed ?? 0) > 0 && ·}
- {(p.wcRed ?? 0) > 0 && (
-
- 🟥 {p.wcRed}
-
- )}
-
- )}
- {p.club && (
-
- {clubIso &&
}
- {clubUrl ? (
-
- {p.club}
-
- ) : (
-
{p.club}
- )}
-
- )}
+
)
diff --git a/src/pages/forecast.css b/src/pages/forecast.css
index b7231cc5..3f2427f1 100644
--- a/src/pages/forecast.css
+++ b/src/pages/forecast.css
@@ -269,50 +269,7 @@ input.sim-matchno {
text-align: center;
}
-/* ============ info dot + tooltip ============ */
-.infodot {
- position: relative;
- display: inline-flex;
- vertical-align: middle;
-}
-.infodot-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- border: none;
- background: none;
- color: var(--text-3);
- cursor: pointer;
- border-radius: 999px;
-}
-.infodot-btn:hover {
- color: var(--accent-text);
-}
-.infodot-tip {
- position: absolute;
- z-index: 60;
- inset-inline-start: 50%;
- top: calc(100% + 6px);
- transform: translateX(-50%);
- width: max-content;
- max-width: 240px;
- padding: 7px 10px;
- border-radius: var(--r-sm);
- background: var(--text);
- color: var(--bg-elev);
- font-size: 0.76rem;
- font-weight: 500;
- line-height: 1.35;
- text-align: start;
- box-shadow: 0 4px 16px #0003;
- display: none;
-}
-.infodot:hover .infodot-tip,
-.infodot:focus-within .infodot-tip,
-.infodot-tip.open {
- display: block;
-}
+/* info dot + tooltip styles are global (shared component) — see index.css */
/* ============ outcome-probability table ============ */
.fc-section h2 {
diff --git a/src/pages/polymarket.css b/src/pages/polymarket.css
new file mode 100644
index 00000000..87870359
--- /dev/null
+++ b/src/pages/polymarket.css
@@ -0,0 +1,148 @@
+/* ============ Prediction market (Polymarket) ============ */
+
+.mkt-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px 18px;
+ margin-bottom: 14px;
+}
+.mkt-meta-stats {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px 16px;
+}
+.mkt-volume {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-weight: 700;
+ color: var(--accent-3);
+}
+.mkt-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.mkt-table-card {
+ /* must stay visible so a header info-tooltip can overflow the card instead of
+ being clipped behind its edge */
+ overflow: visible;
+}
+.mkt-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.92rem;
+}
+.mkt-table th,
+.mkt-table td {
+ padding: 9px 10px;
+ border-bottom: 1px solid var(--border);
+ text-align: right;
+ vertical-align: middle;
+}
+.mkt-table tbody tr:last-child th,
+.mkt-table tbody tr:last-child td {
+ border-bottom: none;
+}
+.mkt-table thead th {
+ font-size: 0.74rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text-2);
+ border-bottom: 2px solid var(--border-strong);
+ white-space: nowrap;
+}
+.mkt-th-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ justify-content: flex-end;
+}
+
+.mkt-rank-h,
+.mkt-rank {
+ text-align: center;
+ width: 2.2em;
+ color: var(--text-3);
+}
+.mkt-team-h,
+.mkt-team {
+ text-align: left;
+}
+.mkt-team a {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ color: inherit;
+ text-decoration: none;
+ font-weight: 600;
+}
+.mkt-team a:hover .mkt-team-name {
+ text-decoration: underline;
+}
+.mkt-team-name {
+ white-space: nowrap;
+}
+
+/* the win-chance bar fills the cell; min-width keeps it readable on phones */
+.mkt-chance-h,
+.mkt-chance {
+ width: 42%;
+ min-width: 130px;
+}
+.mkt-bar-wrap {
+ position: relative;
+ display: flex;
+ align-items: center;
+ height: 22px;
+}
+.mkt-bar {
+ height: 100%;
+ min-width: 2px;
+ border-radius: 5px;
+ background: color-mix(in oklab, var(--accent-2) 70%, transparent);
+}
+.mkt-chance-val {
+ position: absolute;
+ right: 6px;
+ font-weight: 700;
+ font-size: 0.86rem;
+}
+
+.mkt-price {
+ color: var(--text-2);
+}
+.mkt-chg-h,
+.mkt-chg {
+ width: 5em;
+ color: var(--text-3);
+ white-space: nowrap;
+}
+.mkt-chg.up {
+ color: var(--accent-3);
+}
+.mkt-chg.down {
+ color: var(--accent-text);
+}
+
+.mkt-disclaimer {
+ margin: 12px 2px 0;
+}
+
+@media (max-width: 520px) {
+ .mkt-table th,
+ .mkt-table td {
+ padding: 8px 6px;
+ }
+ /* drop the raw market price on narrow screens; the normalized chance is enough */
+ .mkt-price-h,
+ .mkt-price {
+ display: none;
+ }
+}
diff --git a/src/pages/teamdetail.css b/src/pages/teamdetail.css
index 598c1eca..d7d5d751 100644
--- a/src/pages/teamdetail.css
+++ b/src/pages/teamdetail.css
@@ -146,9 +146,9 @@
.td-wiki-icon:hover {
opacity: 1;
}
-/* on the player age row the icon sits flush against the right edge */
-.td-p-age .td-wiki-icon {
- margin-inline-start: auto;
+/* player Wikipedia link sits after the club name on the bottom row */
+.td-p-club .td-wiki-icon {
+ flex: none;
}
.td-skel {
@@ -299,11 +299,14 @@
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow);
- padding: 10px 11px;
+ padding: 10px;
display: flex;
- flex-direction: column;
- gap: 7px;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 10px;
min-width: 0;
+ min-height: 112px;
+ overflow: hidden;
scroll-margin-top: calc(var(--hdr-h, 58px) + 16px);
}
/* deep-linked player card flashes once to draw the eye */
@@ -322,12 +325,10 @@
}
}
.td-no {
- position: absolute;
- top: 10px;
- inset-inline-end: 12px;
+ flex: none;
font-family: var(--font-display);
font-weight: 800;
- font-size: 1.05rem;
+ font-size: 0.95rem;
color: var(--text-3);
line-height: 1;
}
@@ -337,15 +338,62 @@
opacity: 0.65;
}
+.td-p-body {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-inline-end: 2px;
+}
+.td-p-aside {
+ flex: none;
+ width: 64px;
+ align-self: stretch;
+ display: flex;
+ overflow: hidden;
+ background: transparent;
+}
+/* zoom 2× from the top centre so the card shows only the top 50% of the source photo */
+.td-avatar {
+ width: 100%;
+ height: 100%;
+ min-height: 100px;
+ flex: 1;
+ object-fit: cover;
+ object-position: center top;
+ transform: scale(2);
+ transform-origin: center top;
+ background: transparent;
+ border: none;
+}
+.td-avatar-ph {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-display);
+ font-weight: 800;
+ font-size: 1.1rem;
+ color: var(--text-3);
+ letter-spacing: 0.02em;
+ background: transparent;
+}
.td-p-name {
display: flex;
align-items: center;
gap: 6px;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ min-width: 0;
font-weight: 700;
font-size: 0.92rem;
line-height: 1.25;
}
+.td-p-name > .clip {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
.td-cap {
display: inline-flex;
align-items: center;
@@ -362,7 +410,7 @@
.td-p-rows {
display: flex;
flex-direction: column;
- gap: 3px;
+ gap: 4px;
font-size: 0.78rem;
color: var(--text-2);
}
@@ -396,7 +444,7 @@
margin-top: 4px;
}
.td-skel-card {
- height: 118px;
+ height: 130px;
border-radius: var(--r-md);
background: var(--bg-sunken);
border: 1px solid var(--border);
diff --git a/src/types.ts b/src/types.ts
index 25bc3eaf..f536f732 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -201,6 +201,7 @@ export interface SquadPlayer {
clubWiki?: string | null // club's English Wikipedia article URL
captain: boolean
wiki: string | null // English Wikipedia article URL
+ photo?: string | null // official FIFA headshot base URL (digitalhub.fifa.com)
}
export interface TeamSquad {
@@ -268,6 +269,27 @@ export interface Stats {
fairPlay?: { group: Record
; all: Record }
}
+// ---- prediction-market odds (Polymarket, via scripts/update.mjs) ----
+
+export interface MarketOutcome {
+ code: string // FIFA team code
+ label: string // Polymarket's own label (English)
+ price: number // raw Yes-share price = implied probability incl. book overround (0..1)
+ norm: number // price normalized so all outcomes sum to 1
+ change1d: number // 24h price change (signed, in probability points)
+}
+
+export interface MarketOdds {
+ updatedAt: string // when our pipeline fetched it (UTC ISO)
+ marketUpdatedAt: string | null // Polymarket's own last-update timestamp
+ title: string
+ slug: string
+ url: string
+ volume: number // total USD traded
+ source: string
+ champion: MarketOutcome[] // sorted by price, descending
+}
+
export interface Meta {
updatedAt: string
season: string