From 7df45e0c46d82bd9d49260bd21ffc0e71b5af6fd Mon Sep 17 00:00:00 2001 From: Tim Thomas <0800tim@gmail.com> Date: Fri, 5 Jun 2026 16:45:29 +1200 Subject: [PATCH 01/14] style(home): clean 80%-to-bottom linear fade on banner image Tim 2026-06-05 (after the marketing publish): > it should fade out to transparent at the bottom so it blends in > nicely and we don't get this kind of line where the first > paragraph is under the heading in the bottom 20% of the banner > image. You just fade that from the 20% point at the bottom down > to the very bottom, gradient fade to transparent. The existing mask had three stops (opaque to 68%, half-opacity at 88%, transparent at 100%) which left a visible threshold band around the lede. Rewritten as a straight linear ramp from 80% opaque to 100% transparent so the player-photo edge dissolves into the page background. Both -webkit- and unprefixed mask-image forms updated together so iOS Safari + every chromium fork picks it up. CSS-only, one rule (.vt-home-hero-bg). Headline-area dark overlay above the mask is unchanged so legibility on the upper 80% is unaffected. Signed-off-by: Tim Thomas <0800tim@gmail.com> --- apps/web/app/home.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/app/home.css b/apps/web/app/home.css index d71ebe01..f0f46898 100644 --- a/apps/web/app/home.css +++ b/apps/web/app/home.css @@ -74,22 +74,22 @@ * legibility. */ opacity: 0.78; filter: saturate(1); - /* Tim 2026-06-05: soft fade-to-transparent on the bottom edge so - * the banner doesn't bump up against the lede paragraph with a - * hard horizontal line where the player photos end. The dark - * overlay above still keeps the headline readable. */ + /* Tim 2026-06-05 (rev 2): fade the bottom 20% of the banner to + * transparent so the lede paragraph that sits under the banner + * blends in instead of butting against a hard horizontal line + * where the player photos end. Clean linear ramp: opaque to + * 80%, fully transparent at the bottom. The dark overlay above + * still keeps the headline readable through the upper 80%. */ -webkit-mask-image: linear-gradient( 180deg, #000 0%, - #000 68%, - rgba(0, 0, 0, 0.4) 88%, + #000 80%, transparent 100% ); mask-image: linear-gradient( 180deg, #000 0%, - #000 68%, - rgba(0, 0, 0, 0.4) 88%, + #000 80%, transparent 100% ); } From 306f6629510e1d32c1c3414625ce905ba2ff147d Mon Sep 17 00:00:00 2001 From: Tim Thomas <0800tim@gmail.com> Date: Sat, 6 Jun 2026 14:19:05 +1200 Subject: [PATCH 02/14] docs: add match card venue/time footer + overlay design spec Brainstorm output. Replaces the unused 'Add score' toggle and the small top-right ellipsis link on each match row with a single neutral charcoal lozenge showing the user's local kickoff date/time and a gold info icon, opening the existing MatchOverlay. Extends that overlay with a stage chip, dual-timezone When block, and a Where block surfacing city, country, stadium real name, FIFA tournament name, and capacity. Architecture: direct prop + small lookup module (Approach A). Plumbs hostCityId from canonical fixtures through resolveMatch and MatchPredictionRow. New apps/web/lib/host-cities.ts wraps the existing data/fifa-wc-2026/host-cities.json. Refs: sessions/ Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tim Thomas <0800tim@gmail.com> --- ...26-06-06-match-card-venue-footer-design.md | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md diff --git a/docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md b/docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md new file mode 100644 index 00000000..00fd67e3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md @@ -0,0 +1,310 @@ +# Match card venue/time footer + extended match overlay — design + +**Date:** 2026-06-06 +**Author:** Tim (via brainstorming session) +**Touches:** `apps/web/components/bracket/MatchPredictionRow.tsx`, `apps/web/components/overlay/MatchOverlay.tsx`, `apps/web/components/overlay/overlay.css`, `apps/web/app/world-cup-2026/bracket.css`, `apps/web/app/match/[id]/preview/_lib/match-data.ts`. New files: `apps/web/components/bracket/MatchVenueFooter.tsx`, `apps/web/lib/host-cities.ts`. + +## Why + +The "ADD SCORE" toggle at the foot of every match row is not used during the FIFA World Cup 2026 prediction game (predictions are W/D/L only, scores are decorative for now). The space is more valuable showing the user *when* the match kicks off in their own timezone and *where* it's played, with a tap into more detail. + +The existing top-right `⋯` link on each row opens a `MatchOverlay` bottom sheet, but is too small to tap on mobile and is easily missed. Replacing the score toggle with a full-bleed lozenge that opens the same overlay gives one clear, accessible affordance per row. + +## Scope + +In scope: + +- Remove the "Add score / Hide scores" toggle and score inputs from `MatchPredictionRow`. +- Remove the top-right `⋯` link from `MatchPredictionRow` (now redundant). +- Add a new `MatchVenueFooter` component as the row's bottom element: a neutral charcoal lozenge showing date + user-local time + TZ abbreviation + gold info icon, full-width tappable, opening the existing `MatchOverlay`. +- Extend `MatchOverlay` with: a stage / matchday chip, a "When" block (user-local time + venue-local time + full date), a "Where" block (city, country, country flag, real stadium name, FIFA tournament name, capacity). Fix the existing `formatKickoff` bug that hard-codes `en-NZ` + UTC. +- New `apps/web/lib/host-cities.ts` lookup module over the existing `data/fifa-wc-2026/host-cities.json`. +- Plumb `hostCityId` from canonical fixtures through `resolveMatch()` and as a new prop on `MatchPredictionRow`. + +Out of scope (parked for `IDEAS.md` if needed): + +- Removing `homeScore` / `awayScore` from the `MatchPrediction` spec — cross-package, useless now, future tournaments may want it back. +- A "Related matches" tab in the overlay (FIFA reference has one). +- A map pin / coords visualisation. +- Internationalising the overlay caption strings ("your time", "local kickoff") — existing overlay copy isn't i18n'd; out of scope for this PR. + +## Data available + +Already in the repo: + +- `data/fifa-wc-2026/fixtures.json` — every fixture has `host_city_id`, `kickoff_utc`. +- `data/fifa-wc-2026/host-cities.json` — for each `host_city_id`: `city`, `country` (ISO-2), `stadium` (real name), `stadium_tournament_name` (FIFA-imposed name), `capacity`, `timezone` (IANA), `coords`. +- `packages/bracket-engine/data/fifa-wc-2026-fixtures.json` — the engine's view of fixtures, currently carries `venue` as the stadium name string (not the city id). +- `apps/web/app/match/[id]/preview/_lib/match-data.ts` — exports `resolveMatch()` returning a `ResolvedMatch`. Already includes `kickoffUtc` and `venue` (stadium name string). Will be extended with `hostCityId`. + +## Architecture decision + +Approach **A** (chosen, see brainstorming dialog): direct prop + small lookup module. + +- New prop `hostCityId?: string` on `MatchPredictionRow`, mirroring how `kickoffIso` is already plumbed from `GroupCard` / `KnockoutMatch`. +- New `apps/web/lib/host-cities.ts` exposes a synchronous `hostCityById(id)` lookup. +- `MatchVenueFooter` and `MatchOverlay` both read host-city data through the same helper. +- Time formatting handled inline via `Intl.DateTimeFormat`; no new date library. + +Approaches B (`MatchFixtureProvider` context) and C (resolve host-city from `matchId` inside the row) were considered and rejected — B adds a new abstraction with one consumer, C couples the bracket row to canonical FIFA-2026 data and breaks the row's "pure controlled component" contract. + +## Footer (row-level lozenge) + +### Layout + +``` +┌────────────────────────────────────────────────┐ +│ Sat 13 Jun · 11:00 AM (NZT) ⓘ │ +└────────────────────────────────────────────────┘ +``` + +- Single line, centred, full-width tap target inside the row. +- Date + middle-dot + time + small TZ abbreviation in parens + gold info icon at the right end. +- No "Tap for details" text — the icon plus tap-feedback covers it. + +### Styling + +Matches the visual vocabulary of the unselected DRAW pill (`.mpr-pick-draw-pill`) and the team chips in the predicted-standings panel. + +- `border-radius: 999px` +- `background: #1c1c22` +- `border: 1px solid rgba(82, 82, 92, 0.55)` +- Inner padding `8px 16px`. Min-height `36px` desktop, `40px` mobile (gives a ~44px tap target with border). +- Centred horizontally, ~80% of row width up to `420px` max. + +Typography: + +- Date + time: 12px, colour `#cbd5e1`, `font-variant-numeric: tabular-nums`. +- TZ abbreviation: 11px, colour `rgba(148, 163, 184, 0.75)`. +- Middle-dot separator with 8px margins. + +Info icon: + +- 14×14 inline SVG, `i` glyph inside a circle. +- Fill `var(--vt-gold-400, #dca94b)` at rest; `--vt-gold-300` on hover/focus. +- Small `0 0 6px rgba(220, 169, 75, 0.35)` drop-shadow. +- `aria-hidden="true"` — the button's accessible name carries the meaning. + +States: + +- Hover: border `rgba(220, 169, 75, 0.45)`, background `#22222a`, cursor pointer. +- Focus-visible: 2px gold ring `box-shadow: 0 0 0 2px var(--vt-gold-400)`. +- Active: `transform: scale(0.99)`. +- Match started / locked: lozenge stays interactive (viewing details is always allowed after kickoff). + +### Behaviour + +- Single ` -
- - {showScores && ( -
- - - -
- )} -
+ {kickoffIso && ( + + )} ); } diff --git a/apps/web/components/bracket/MatchVenueFooter.tsx b/apps/web/components/bracket/MatchVenueFooter.tsx new file mode 100644 index 00000000..7871fb61 --- /dev/null +++ b/apps/web/components/bracket/MatchVenueFooter.tsx @@ -0,0 +1,160 @@ +/** + * MatchVenueFooter, the neutral charcoal lozenge that sits at the + * bottom of each match row in place of the old "Add score" toggle. + * + * Renders: weekday + date + middle-dot + user-local kickoff time + + * short timezone abbreviation in parens + a small gold info icon. + * + * Whole lozenge is a single tap target. When the bracket's overlay + * router is mounted (the common case inside `/world-cup-2026`), the + * click opens the existing `MatchOverlay` bottom sheet for this + * match. Outside the bracket shell (tests, standalone match-preview + * page) the underlying `` falls through to a regular navigation + * to `/match/{id}/preview`. + * + * SSR / hydration: the first render uses the venue's IANA timezone + * so the server output is deterministic. After mount, a `useEffect` + * reads the user's resolved timezone (`Intl.DateTimeFormat() + * .resolvedOptions().timeZone`) and re-renders with that. The DOM + * structure is identical pre/post hydration, only the formatted + * text changes, which React tolerates. + */ + +"use client"; + +import { useEffect, useState, type MouseEvent } from "react"; + +import { useOptionalOverlay } from "@/components/overlay/OverlayProvider"; + +import type { HostCity } from "@/lib/host-cities"; + +export interface MatchVenueFooterProps { + readonly matchId: string; + readonly homeName: string; + readonly awayName: string; + /** ISO-8601 kickoff time in UTC, e.g. "2026-06-11T19:00:00Z". */ + readonly kickoffIso: string; + /** Resolved host-city record. When absent (defensive), the lozenge + * falls back to UTC formatting. */ + readonly hostCity?: HostCity; +} + +interface FormattedKickoff { + readonly dateLabel: string; + readonly timeLabel: string; +} + +function formatKickoff( + iso: string, + timezone: string, + locale?: string, +): FormattedKickoff { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + return { dateLabel: "TBD", timeLabel: "TBD" }; + } + // `undefined` locale lets Intl resolve to the runtime locale; the + // explicit `locale` argument is for the SSR pre-hydration pass + // where we want a stable, deterministic output. + const loc = locale; + const dateLabel = new Intl.DateTimeFormat(loc, { + weekday: "short", + day: "numeric", + month: "short", + timeZone: timezone, + }).format(d); + const timeLabel = new Intl.DateTimeFormat(loc, { + hour: "numeric", + minute: "2-digit", + timeZone: timezone, + timeZoneName: "short", + }).format(d); + return { dateLabel, timeLabel }; +} + +function resolveUserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +export function MatchVenueFooter(props: MatchVenueFooterProps) { + const { matchId, homeName, awayName, kickoffIso, hostCity } = props; + const overlay = useOptionalOverlay(); + + // SSR / first-render pass: use the venue timezone (deterministic + // across server + client). After mount, switch to the user's + // resolved timezone via Intl. The venue fallback also covers the + // rare case where Intl can't resolve a user timezone. + const ssrTimezone = hostCity?.timezone ?? "UTC"; + const ssrLocale = "en-US"; + const [timezone, setTimezone] = useState(ssrTimezone); + const [locale, setLocale] = useState(ssrLocale); + + useEffect(() => { + setTimezone(resolveUserTimezone()); + // `undefined` here = use the runtime-resolved locale. + setLocale(undefined); + }, []); + + const { dateLabel, timeLabel } = formatKickoff(kickoffIso, timezone, locale); + const ariaLabel = + `View match details for ${homeName} vs ${awayName}, ` + + `kicks off ${dateLabel}, ${timeLabel}`; + + const onClick = (e: MouseEvent): void => { + if (!overlay) return; // let the navigate normally + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) { + return; // user wants a new tab / etc., honour it + } + e.preventDefault(); + overlay.open("match", { id: matchId }); + }; + + return ( + + + {dateLabel} + + {timeLabel} + + + + ); +} + +interface InfoIconProps { + readonly className?: string; +} + +function InfoIcon(props: InfoIconProps) { + return ( + + ); +} diff --git a/apps/web/components/overlay/MatchOverlay.tsx b/apps/web/components/overlay/MatchOverlay.tsx index 9d5b1619..d18081b8 100644 --- a/apps/web/components/overlay/MatchOverlay.tsx +++ b/apps/web/components/overlay/MatchOverlay.tsx @@ -1,9 +1,23 @@ /** * MatchOverlay, bottom-sheet card with the compact match-preview view. * - * Renders a slimmed-down version of the match-preview surface: kickoff - * label + venue, both team flags, and quick links to each team's - * overlay (replaces self). + * Layout (top → bottom): + * - Stage chip: "Group A · Match 1" or "Round of 32 · Match 73", + * small gold-bordered lozenge. + * - Two team side-cards (unchanged): both flags, names, codes, + * tappable into the team overlay. + * - When block: full date, large user-local kickoff time, smaller + * venue-local kickoff time. When the user's timezone matches the + * venue timezone, collapses to a single line. + * - Where block: city + host-country flag + country name, real + * stadium name, FIFA tournament name in quotes + formatted + * stadium capacity. + * + * The When block formats date/time client-side via `Intl.DateTimeFormat` + * using the runtime-resolved locale + timezone. On first paint + * (SSR / pre-hydration) we render both lines in the venue timezone so + * the markup is deterministic; a `useEffect` swaps to the user's + * resolved timezone after mount. * * Tim 2026-06-05: the "Full page →" header CTA and the "Open full * preview (Predict / H2H / Form / Lineup / Stats) →" footer CTA were @@ -14,12 +28,13 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { loadFixtures2026 } from "@tournamental/bracket-engine"; import { TeamFlag } from "@/components/bracket/TeamFlag"; import { enrichTournamentTeams, type CanonicalTeamsFile } from "@/lib/bracket/enrich"; +import { hostCityById, type HostCity } from "@/lib/host-cities"; import canonicalTeamsRaw from "@/../../data/fifa-wc-2026/teams.json"; import { @@ -69,8 +84,8 @@ export function MatchOverlay(props: MatchOverlayProps) { const homeName = home?.name ?? match.homeCode ?? "TBD"; const awayName = away?.name ?? match.awayCode ?? "TBD"; - const kickoff = new Date(match.kickoffUtc); - const kickoffLabel = formatKickoff(kickoff); + const hostCity = hostCityById(match.hostCityId); + const stageChipText = `${match.stageLabel} · Match ${match.matchNo}`; return (
- {match.stageLabel} - - {match.venue && {match.venue}} + {stageChipText}
@@ -105,6 +118,12 @@ export function MatchOverlay(props: MatchOverlayProps) { onOpenTeam={(code) => overlay.replace("team", { code })} />
+ + {match.kickoffUtc && ( + + )} + + {hostCity && }
); @@ -152,14 +171,134 @@ function SideCard(props: SideCardProps) { ); } -function formatKickoff(d: Date): string { - return d.toLocaleString("en-NZ", { +// ---------- When block ---------- + +interface WhenBlockProps { + readonly kickoffUtc: string; + readonly hostCity?: HostCity; +} + +function WhenBlock(props: WhenBlockProps) { + const { kickoffUtc, hostCity } = props; + const venueTz = hostCity?.timezone ?? "UTC"; + + // SSR: render both lines in venue TZ for a deterministic first + // paint. Client: swap the "your time" line to the user's resolved + // TZ via `Intl`. Layout (two lines) is identical pre/post hydration. + const [userTz, setUserTz] = useState(venueTz); + const [hydrated, setHydrated] = useState(false); + useEffect(() => { + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || venueTz; + setUserTz(tz); + } catch { + // ignore; venue TZ stays as the user TZ + } + setHydrated(true); + }, [venueTz]); + + const d = new Date(kickoffUtc); + const dateLabel = new Intl.DateTimeFormat(undefined, { weekday: "short", day: "numeric", month: "short", - hour: "2-digit", + year: "numeric", + timeZone: userTz, + }).format(d); + const userTimeLabel = new Intl.DateTimeFormat(undefined, { + hour: "numeric", minute: "2-digit", - timeZone: "UTC", + timeZone: userTz, timeZoneName: "short", - }); + }).format(d); + const venueTimeLabel = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone: venueTz, + timeZoneName: "short", + }).format(d); + + // Same-zone collapse: only meaningful after hydration, so the + // server still emits the two-line shape. + const sameZone = hydrated && userTz === venueTz; + + return ( +
+
{dateLabel}
+
+ {userTimeLabel} + + {sameZone ? "kickoff" : "your time"} + +
+ {!sameZone && ( +
+ {venueTimeLabel} + local kickoff +
+ )} +
+ ); +} + +// ---------- Where block ---------- + +interface WhereBlockProps { + readonly hostCity: HostCity; +} + +function WhereBlock(props: WhereBlockProps) { + const { hostCity } = props; + const countryFlag = countryFlagEmoji(hostCity.country); + const countryName = countryDisplayName(hostCity.country); + const stadiumDiffers = + hostCity.stadium_tournament_name !== hostCity.stadium; + const capacityFormatted = new Intl.NumberFormat(undefined).format( + hostCity.capacity, + ); + + return ( +
+
+ + + {hostCity.city}, {countryName} + +
+
{hostCity.stadium}
+
+ {stadiumDiffers && ( + <> + Officially “{hostCity.stadium_tournament_name}” + + + )} + {capacityFormatted} seats +
+
+ ); +} + +// ---------- helpers ---------- + +function countryFlagEmoji(iso2: string): string { + if (iso2.length !== 2) return ""; + const a = iso2.toUpperCase().charCodeAt(0); + const b = iso2.toUpperCase().charCodeAt(1); + if (a < 65 || a > 90 || b < 65 || b > 90) return ""; + return String.fromCodePoint(0x1f1e6 + (a - 65), 0x1f1e6 + (b - 65)); +} + +function countryDisplayName(iso2: string): string { + try { + return ( + new Intl.DisplayNames(undefined, { type: "region" }).of( + iso2.toUpperCase(), + ) ?? iso2 + ); + } catch { + return iso2; + } } diff --git a/apps/web/components/overlay/team-overlay.css b/apps/web/components/overlay/team-overlay.css index 95d74d8e..be640af1 100644 --- a/apps/web/components/overlay/team-overlay.css +++ b/apps/web/components/overlay/team-overlay.css @@ -160,6 +160,24 @@ color: #fbbf24; } +/* Stage chip, single rounded lozenge anchored at the top of the + * sheet. Replaces the older bare `.vt-match-overlay-stage` span and + * mirrors the visual vocabulary of the bracket's team chips and DRAW + * pill so the overlay reads as in-family. */ +.vt-match-overlay-stage-chip { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 999px; + background: rgba(220, 169, 75, 0.08); + border: 1px solid rgba(220, 169, 75, 0.4); + color: #dca94b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + .vt-match-overlay-row { display: grid; grid-template-columns: 1fr auto 1fr; @@ -222,6 +240,88 @@ font-weight: 500; } +/* ---------- When block (dual-timezone kickoff) ---------- */ + +.vt-match-overlay-when { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +[data-theme="light"] .vt-match-overlay-when { + border-top-color: rgba(15, 23, 42, 0.06); + border-bottom-color: rgba(15, 23, 42, 0.06); +} + +.vt-match-overlay-when-date { + font-size: 13px; + opacity: 0.75; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.vt-match-overlay-when-row { + display: flex; + align-items: baseline; + gap: 10px; + font-variant-numeric: tabular-nums; +} + +.vt-match-overlay-when-primary .vt-match-overlay-when-time { + font-size: 22px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.vt-match-overlay-when-secondary .vt-match-overlay-when-time { + font-size: 14px; + font-weight: 500; + opacity: 0.85; +} + +.vt-match-overlay-when-caption { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; +} + +/* ---------- Where block (venue detail) ---------- */ + +.vt-match-overlay-where { + display: flex; + flex-direction: column; + gap: 4px; +} + +.vt-match-overlay-where-city { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + opacity: 0.75; + letter-spacing: 0.02em; +} + +.vt-match-overlay-where-flag { + font-size: 16px; + line-height: 1; +} + +.vt-match-overlay-where-stadium { + font-size: 16px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.vt-match-overlay-where-meta { + font-size: 12px; + opacity: 0.65; +} + .vt-match-overlay-actions { display: flex; justify-content: center; diff --git a/apps/web/lib/host-cities.ts b/apps/web/lib/host-cities.ts new file mode 100644 index 00000000..f08c0cf1 --- /dev/null +++ b/apps/web/lib/host-cities.ts @@ -0,0 +1,105 @@ +/** + * Host-city lookup, thin wrapper over the canonical + * `data/fifa-wc-2026/host-cities.json` file. + * + * - `hostCityById(id)` returns the rich `HostCity` record (city, + * country, real stadium name, FIFA tournament name, capacity, IANA + * timezone, coords) for a given fixture's `host_city_id`. + * - `allHostCities()` is mostly for tests / migrations; production + * pages should look up by id. + * + * Synchronous, O(1) after module load. Safe to call from server + * components and client components alike. + */ + +import raw from "../../../data/fifa-wc-2026/host-cities.json"; +import rawFixtures from "../../../data/fifa-wc-2026/fixtures.json"; + +export interface HostCity { + readonly id: string; + readonly city: string; + /** ISO-3166 alpha-2, e.g. "MX", "US", "CA". */ + readonly country: string; + /** Real-world stadium name, e.g. "Estadio Azteca". */ + readonly stadium: string; + /** FIFA-imposed tournament name, e.g. "Estadio Banorte". May equal + * `stadium` if FIFA hasn't renamed it. */ + readonly stadium_tournament_name: string; + readonly capacity: number; + /** IANA timezone, e.g. "America/Mexico_City". */ + readonly timezone: string; + /** [lat, lon]. Typed loosely so it round-trips the raw JSON without + * a tuple-narrowing cast; consumers should index `[0]`/`[1]`. */ + readonly coords: readonly number[]; +} + +interface HostCitiesFile { + readonly host_cities: readonly HostCity[]; +} + +const ALL: readonly HostCity[] = (raw as HostCitiesFile).host_cities; +const BY_ID: ReadonlyMap = new Map( + ALL.map((c) => [c.id, c]), +); + +export function hostCityById(id: string | null | undefined): HostCity | undefined { + if (!id) return undefined; + return BY_ID.get(id); +} + +export function allHostCities(): readonly HostCity[] { + return ALL; +} + +// ---------- match number → host city ---------- + +interface CanonicalFixtureRow { + readonly match_number: number; + readonly host_city_id?: string; + readonly kickoff_utc?: string; +} + +interface FixturesFile { + readonly fixtures: readonly CanonicalFixtureRow[]; +} + +const ALL_FIXTURES: readonly CanonicalFixtureRow[] = + (rawFixtures as FixturesFile).fixtures; + +const HOST_CITY_BY_MATCH_NO: ReadonlyMap = new Map( + ALL_FIXTURES + .filter((f): f is CanonicalFixtureRow & { host_city_id: string } => + typeof f.host_city_id === "string" && f.host_city_id.length > 0, + ) + .map((f) => [f.match_number, f.host_city_id]), +); + +const KICKOFF_BY_MATCH_NO: ReadonlyMap = new Map( + ALL_FIXTURES + .filter((f): f is CanonicalFixtureRow & { kickoff_utc: string } => + typeof f.kickoff_utc === "string" && f.kickoff_utc.length > 0, + ) + .map((f) => [f.match_number, f.kickoff_utc]), +); + +/** + * Convenience helper for the bracket UI: resolves a fixture's + * `match_number` (1..104 in FIFA 2026) directly to its rich + * `HostCity` record. Bracket-engine `GroupFixture` / `KnockoutFixture` + * only carry the stadium name string, not the host-city id, so the + * row's parents call this to populate the `hostCity` prop. + */ +export function hostCityByMatchNumber(matchNumber: number): HostCity | undefined { + const id = HOST_CITY_BY_MATCH_NO.get(matchNumber); + return id ? hostCityById(id) : undefined; +} + +/** + * Kickoff ISO timestamp for a fixture's `match_number`. Useful for + * `KnockoutMatch`, where the upstream `CascadedKnockout` strips the + * kickoff field and the component needs it to render the new venue + * footer lozenge without a parent-prop change. + */ +export function kickoffIsoByMatchNumber(matchNumber: number): string | undefined { + return KICKOFF_BY_MATCH_NO.get(matchNumber); +} From 087c2170c52416f93039804b8968efa54a0126c7 Mon Sep 17 00:00:00 2001 From: Tim Thomas <0800tim@gmail.com> Date: Sat, 6 Jun 2026 15:42:40 +1200 Subject: [PATCH 04/14] feat(bracket): blurred-flag side cards, TBD note, tighter knockout lozenge, smaller TZ Round-two polish from Tim's review of the v1 build (2026-06-06): - MatchOverlay When block now splits the kickoff time from its timezone abbreviation via Intl.DateTimeFormat.formatToParts(), so the primary line reads '08:00' at heading size + 'GMT+12 your time' at caption size. Fixes the over-large 'GMT+12' that was reading as if it were part of the headline number. - MatchOverlay SideCard renders a blurred full-bleed flag behind the circular team chip when the team is known (group stage popups + resolved knockout slots). The .vt-match-overlay-side- tbd variant for unresolved knockout slots keeps the plain card with the '?' glyph, matching the bracket's TBD treatment. - New 'Teams shown once the previous stage closes' italic caption appears under the Sheet title when one or both sides are still TBD. Singular 'Opponent shown ...' when only one side is TBD. - Knockout cards (.km-card .mpr-venue-footer) now use a tighter lozenge: smaller padding, lower min-height, narrower width band. Combined with .km-team min-height bumped 56px -> 76px so the flag art gets back the vertical real estate the lozenge was consuming. Group rows are unchanged. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tim Thomas <0800tim@gmail.com> --- apps/web/app/world-cup-2026/bracket.css | 35 ++++++++- apps/web/components/overlay/MatchOverlay.tsx | 81 ++++++++++++++++---- apps/web/components/overlay/team-overlay.css | 73 +++++++++++++++++- 3 files changed, 169 insertions(+), 20 deletions(-) diff --git a/apps/web/app/world-cup-2026/bracket.css b/apps/web/app/world-cup-2026/bracket.css index 9265a2cc..f92a549c 100644 --- a/apps/web/app/world-cup-2026/bracket.css +++ b/apps/web/app/world-cup-2026/bracket.css @@ -1336,6 +1336,32 @@ a.bracket-share-cta-primary { transform: scale(0.99); } +/* Knockout cards are visually denser than group rows (smaller box, + * full-bleed flag art) so the lozenge tightens up inside them: less + * padding, lower min-height, narrower width band. The team + * rectangles keep the visual weight; the lozenge sits underneath + * as a quiet caption (Tim 2026-06-06). */ +.km-card .mpr-venue-footer { + width: min(280px, 96%); + min-height: 28px; + margin-top: 4px; + padding: 4px 12px; + gap: 6px; +} +@media (max-width: 768px) { + .km-card .mpr-venue-footer { + min-height: 32px; + } +} +.km-card .mpr-venue-footer-text { + font-size: 11px; + gap: 6px; +} +.km-card .mpr-venue-footer-info { + width: 12px; + height: 12px; +} + .mpr-venue-footer-text { display: inline-flex; align-items: baseline; @@ -1820,12 +1846,17 @@ a.bracket-share-cta-primary { background-color: #25252c; border: 1px solid transparent; border-radius: 12px; - padding: 10px 12px; + padding: 12px 12px; cursor: pointer; font-size: 13px; color: #cbd5e1; text-align: left; - min-height: 56px; + /* Bumped from 56px to 76px (Tim 2026-06-06) so the blurred flag + * background has more vertical room to read clearly. With the + * new venue lozenge sitting underneath, the card gained vertical + * stack height; this redistributes that gain back into the team + * rectangles where the flag art actually lives. */ + min-height: 76px; overflow: hidden; isolation: isolate; transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease; diff --git a/apps/web/components/overlay/MatchOverlay.tsx b/apps/web/components/overlay/MatchOverlay.tsx index d18081b8..62808f1d 100644 --- a/apps/web/components/overlay/MatchOverlay.tsx +++ b/apps/web/components/overlay/MatchOverlay.tsx @@ -28,7 +28,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { loadFixtures2026 } from "@tournamental/bracket-engine"; @@ -86,6 +86,13 @@ export function MatchOverlay(props: MatchOverlayProps) { const hostCity = hostCityById(match.hostCityId); const stageChipText = `${match.stageLabel} · Match ${match.matchNo}`; + const someTbd = !match.homeCode || !match.awayCode; + const bothTbd = !match.homeCode && !match.awayCode; + const tbdNote = someTbd + ? bothTbd + ? "Teams shown once the previous stage closes" + : "Opponent shown once the previous stage closes" + : null; return (
+ {tbdNote && ( +

({tbdNote})

+ )}
{stageChipText}
@@ -150,10 +160,19 @@ function SideCard(props: SideCardProps) {
); } + // Inline a CSS variable carrying the team's flag URL so the + // `.vt-match-overlay-side[data-team-bg]::before` pseudo can paint a + // blurred full-bleed flag behind the circular chip + name. Same + // pattern the bracket's `.km-team` cells use. + const bgStyle: CSSProperties = { + ["--vt-side-bg" as string]: `url(/flags/${code}.svg)`, + }; return ( + + ); + }) + )} + + {editAllowedCountries.length < MAX_ALLOWED_COUNTRIES && ( +
+ +
+ )} + {editAllowedCountries.length >= MAX_ALLOWED_COUNTRIES && ( + + Maximum {MAX_ALLOWED_COUNTRIES} countries per pool. Remove one to add another. + + )} + + )} + +