# Project Memory — World Cup 2026 Hub

Persistent memory for this project. **Read before any significant change.**

**Fixed structure (keep this order):** Context · Architecture & Decisions · Gotchas ·
Operational Runbooks · Stats Screen · Patterns & How-tos · Current State.

> **Maintenance rule (set 2026-06-17):** this file holds **durable** knowledge only —
> architecture, decisions, gotchas, patterns. Per-match daily-refresh detail lives in **git
> commits** (see the commit convention), *not here*. The **Current State** section keeps a rolling
> window of the **last 3 refreshes** and is **pruned on each update** (do not append new dated
> refresh logs). New *decisions / gotchas / patterns* are appended to their section. Content is kept
> in its original language (EN or PT) where it was written that way; new scaffolding is in English.

---

## Context

Static web app for the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) — schedule, group
standings, interactive knockout bracket with user simulation, stadiums, and a post-tournament stats
screen. All content from `data/*.json`. Started 2026-06-11 from two spec documents; built
step-by-step with user approval between steps; now live with **real** WC2026 data, refreshed daily.

**What it is:** a **personal/portfolio** piece (visual polish is a primary goal); a **static SPA**
(one `index.html`, ES-module vanilla JS, JSON as the only "database"); maintained by **editing JSON
only** — code should never need touching to update scores/teams.

**What it is not:** no backend, database, build step, bundler, CDN dependency, or framework; no
automated tests / linter (explicit spec constraint).

**Spec source of truth:** `world-cup-2026-hub-spec-en.md` + `complement-spec-worldcup2026-en.md`
(**complement wins on conflict**).

---

## Priority objectives

1. **Spec compliance** — complement spec wins on conflict.
2. **Visual quality** — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
3. **Interactive bracket** — hover path highlight, zoom, drag, simulation; the centerpiece feature.
4. **Easy maintenance** — real data drop-in via JSON; `bracket-config.json` is the only structural
   file edited after the group stage.
5. **Performance/accessibility** — Lighthouse > 90, first render < 2s, JS < 300KB, ARIA + keyboard nav.

---

## Architecture & Decisions

### Stack & module pattern
- **Vanilla HTML/CSS/JS ES2022+, ES Modules**, relative paths, no bundler/CDN/framework — spec
  mandate (GitHub Pages / Hostinger serve static files only).
- **EN/PT-BR UI toggle** via `i18n.js`: tiny dict + `t(key)`, persisted in `wc2026_prefs.lang`.
  Static HTML uses `data-i18n` / `data-i18n-aria` re-applied by `applyI18n()`; dynamic renders call
  `t()` and listen for `langchange`. Phases via `translatePhase()` (PT: R32 = "16 avos de final").
  Default language: `navigator.language` startsWith `pt` → PT, else EN; only persisted on toggle.
- **`storage.js`** is the only access path to `localStorage` (`wc2026_*` keys, auto JSON). Holds
  prefs (`lang`, `lastTab`, `timeMode`), `wc2026_favorites`, `wc2026_simulation`.
- **Per-view modules** (`schedule/groups/bracket/stadiums/stats`) + `app.js` entry. **Circular
  imports `app.js` ⇄ view modules are intentional and safe** in native ESM — all cross-calls happen
  at render runtime, after every module has evaluated. `stats.js` imports `getBracketTree`,
  `getFavorites`, `openMatchModal` this way too.
- **Custom events on `document`** drive re-renders — each view owns its own: `langchange`,
  `simchange`, `favchange`, `timemodechange`, `datachange` (live refresh). No shared render loop.

### Data model
- **All match times are UTC** in `matches.json`; converted at render by `formatMatchTime(match,
  stadium, mode)` via `Intl.DateTimeFormat` (`mode` = `"local"` browser tz, or `"stadium"`
  timezone). `.ics` export depends on this.
- **Match ids:** group matches **1–72 = chronological by UTC kickoff** (≠ 6-per-group blocks);
  **73–104 = FIFA official match numbers** (knockout, carry `bracketRef`).
- **Knockout matches carry `bracketRef`, not teams** — resolved at runtime from standings +
  `bracket-config.json`; rounds after R32 have no config and are generated by **sequential pairing
  of winners** (indices 0-1 → 0, 2-3 → 1, …).
- **Simulation never mutates JSON** — overlay in `localStorage.wc2026_simulation`, keyed by
  bracketRef (`R32-6: { winner: "FRA", score: "2-1" }`, score home-away).
- Team ids are **3-letter uppercase** (`MEX`, `BRA`). Knockout ids: `R32-1`…`R32-16`, `R16-1`…,
  `QF-1`…, `SF-1`/`SF-2`, `THIRD-PLACE`, `FINAL`.

### Standings (`groups.js`)
- **Only `status:"finished"` counts** toward standings (live scores ignored until full-time → stable
  standings + deterministic bracket resolution).
- **Tiebreak:** points → goal difference → goals for → team id alphabetical (stable fallback).
- `computeStandings()` (per-group, finished only) and `isGroupFinished()` are exported and reused by
  `bracket.js` / `stats.js` (no recompute).
- **Best third-placed teams table (2026-06-28).** `computeThirdPlaceRanking()` (exported) takes each
  group's 3rd row (`standings[letter][2]`), ranks the 12 across groups by the same key (Pts → GD → GF →
  id) and flags the top 8 `qualified`. Rendered as a full-width section **below** the 12 group cards in
  the Grupos tab, **gated on `allGroupsFinished()`** (meaningless mid-stage → omitted from the DOM).
  Reuses `.standings-table` styling, header tooltips and the favorite-row highlight; gold `.row-third`
  + ✓ for the 8 that advance, muted `.row-out` + — for 9–12, a dashed `.cut` line between 8 and 9. It
  only **ranks** the thirds for display — the slot→group allocation still lives in
  `bracket-config.json` (FIFA combination table), never derived from this ranking.

### Bracket (`bracket.js`)
- **Tree is language-neutral**: slots are `{ teamId }` or `{ ph: {kind,…} }`; placeholder text is
  produced at render time by `slotDisplay()`, so language switches never invalidate the tree.
- **Tree is cached**; `invalidateBracket()` drops it (simulation overlay + live refresh).
- **`resolveBracketTeams(matchOrRef)`** → `{ home, away }` of `{ team: Team|null, label }` for any
  match (group or knockout); reused by schedule cards, modal, and search/team filters (so knockout
  matches become searchable/filterable once resolved). `getBracketTree()` → `{ rounds, third,
  nodesByRef, champion }`.
- ~~**CSS connectors depend on an equal-height invariant:** all columns share height with `flex:1`
  slots, so pair children sit at 25%/75% and the next node at 50%; pure-CSS stubs meet exactly.
  Column gap = 2 × stub (44px desktop / 36px ≤767). **Breaking equal height breaks the lines.**~~
  **RETIRED 2026-07-03** by the wallchart redesign — see "Bracket redesign (2026-07-03)" below;
  connectors are now SVG paths generated from the same JS geometry as the cards, so no CSS
  invariant exists anymore.
- **Simulation:** `decide()` applies only real finished results; `applySimulation()` overlays user
  picks afterwards and **never overrides a real result** (so `simulated:false` ⇒ real). Stale entries
  (winner no longer resolved) are silently ignored. Eligible nodes (both teams resolved, real result
  still `scheduled`) get dashed blue borders + a SIM chip.
- **Interactions:** full-path highlight computed from ref arithmetic (`floor(i/2)` up, `2i`/`2i+1`
  down), no tree lookup. Zoom = CSS `transform:scale()` on the canvas + a sized `#bracket-zoom` box,
  pointer-anchored, clamped 0.4–2; natural size measured lazily (`ensureMeasured()` — panel may be
  `hidden` at render). Pan/pinch via Pointer Events, `touch-action:none` on the wrap. Drag–click
  conflict: capture pointer only **after** the >5px threshold (gotcha #6).
- **Share/import:** `?prediction=base64(simulation)` via `getShareableLink()` /
  `loadPredictionFromURL()`; stripped from the URL (`history.replaceState`) whether applied or not;
  unknown refs rejected wholesale. **Challenge** card scores sim vs real finished knockout results.

### Bracket redesign — center-out wallchart (2026-07-03, Step 1 of 4)
Full redesign spec settled via /grill-me: **two switchable chart layouts** (center-out wallchart =
default, radial = Step 3) + a **mobile round pager** default ≤767px (Step 2), stadium-night art
direction, lean escalating cards, fit-whole-chart initial framing, gold-real/blue-sim champion
celebration (Step 4), built **directly on master** (deploy still gated on user-approved push).
Step 1 (shipped): wallchart replaces the old left-to-right columns.
- **`computeWallchartLayout()` in `bracket.js` is the single geometry source:** absolute px
  positions for every card/title/champion box (`GEO` constants; R32 1–8 left half, 9–16 right,
  later nodes at feeder midpoints) **and** the SVG bezier connectors + champion stem derived from
  the same numbers — cards and lines cannot drift apart. Tree/sim/share/challenge logic untouched;
  DOM contract preserved (`data-ref`, `data-match-id`, delegation, keyboard activation).
- **Fit-to-chart zoom:** geometry is computed (never DOM-measured); a `ResizeObserver` on
  `#bracket-wrap` computes `view.fit` when the panel becomes visible (hidden panel = clientWidth 0)
  and re-fits on resize unless the user zoomed. **Zoom label "100%" = fit**, reset returns to fit,
  clamp = [fit, 2]. The wrap carries an inline `aspect-ratio: W/H` from the engine so the fit view
  is never letterboxed (capped `max-height: min(80vh, 840px)`).
- **Cards:** lean two-row + microline (`.bk-meta`: kickoff via `kickoffShort()` honoring the
  Local/Stadium toggle — bracket now listens to `timemodechange`; LIVE pulse; FT = `t('bracket.ft')`,
  new i18n key EN/PT). Tier classes `bk-r32…bk-final` escalate size/heat; the Final hero card also
  shows venue (`.bk-venue`). Champion box: `has-champion` gold; **`is-sim` = blue + SIM chip**
  (a simulated champion must never read as real — same rule as the stats verdict; previously the
  old champion box showed sim champions in gold).
- **Path highlight** now also lights SVG connectors: a path turns gold when **both** its
  `data-from`/`data-to` endpoints are in `pathRefs()`; the champion stem carries FINAL→FINAL.
- **Motion:** cards rise + connectors draw (`pathLength="1"` + dash animation), staggered by round,
  all inside `@media (prefers-reduced-motion: no-preference)`; replays on each tab open (display:none
  restarts CSS animations) — intentional "chart assembles" effect.
- ~~**Interim states until later steps:** mobile ≤767 shows the pinch-zoom wallchart (pager = Step 2);
  no view toggle yet (registry lands with the second view).~~ Superseded same day — Steps 2+3 below.

### Bracket redesign — Steps 2+3: view toggle, rounds pager, radial "orbit" (2026-07-03)
- **View toggle:** segmented `Fases | Chaveamento | Radial` in the toolbar; explicit choice persists
  in `wc2026_prefs.bracketView`; with no stored pref the default follows the breakpoint (≤767px →
  rounds pager, else wallchart; a `matchMedia('change')` listener re-renders while unset). Zoom
  controls render only for chart views. `render()` dispatches: pager / `chartHTML` →
  `wallchartInnerHTML` | `radialInnerHTML`. Switching chart layouts resets to a fresh fit
  (`view.layoutId` check in `initInteractions`).
- **Rounds pager — button navigation ONLY (user decision):** the first build used a scroll-snap
  swipe track; the user rejected horizontal scrolling, so pages are plain sections toggled with
  `hidden` by the chips (`initPager` ~15 lines, no ResizeObserver/height-clamp needed). Grid is
  **max 2 columns** (≥700px) — 3–4 columns made cards too narrow (user feedback). Opens on the
  first round with an unfinished match (`firstOpenPage`); `pagerIndex` survives re-renders. Cards
  (`.bk-pcard`) carry venue·city + status row and reuse `teamRowHTML` + the same
  `data-ref`/`data-match-id` delegation (modal + sim editor work unchanged).
- **Radial = "orbit" view, redesigned to a user-supplied reference image** (circular predictions
  bracket with the trophy at center). NOT rectangular cards: **circular flag tokens** on concentric
  rings — outer ring = 32 entrants, each ring inward = a round's **winner slots** (a match's winner
  slot doubles as the next match's participant), trophy centerpiece (= the FINAL's winner slot,
  champion flag + name when decided, sim-blue when simulated), third-place = small labeled pair
  below the circle. `TGEO` radii chosen so adjacent/consecutive rings never collide (validated with
  an automated pairwise-overlap eval in preview — keep doing that after any radius change).
  **Semantics:** elbow route lines (radial segment + bend dot) turn **gold = real advancement**,
  **dashed blue = simulated pick**; eliminated entrants grey out (`tk-out`), TBD slots are striped
  discs (`tk-tbd`); names/scores live in the shared app tooltip (`has-tip`/`data-tip`, delegation
  in app.js `initTooltips`) and the modal. **Sim affordance lives on the winner slot** (`opts.slot`,
  not `opts.winner` — a TBD slot is exactly the simulatable one; that inversion was a shipped-then-
  fixed bug). Path highlight generalized: `showPath` lights ANY `[data-ref]` element + `.bk-le`
  endpoints; hover/focus delegation uses `closest('[data-ref]')`.
- **Toolbar gotcha:** `.bracket-tools-left` holds 6 controls — needs `flex-wrap: wrap` or it forces
  ~500px of page overflow at 375px (found via body scrollWidth sweep on mobile).

### Modal (`modal.js`)
- **Native `<dialog>` + `showModal()`** → focus trap, Esc, `::backdrop` come free. Backdrop click =
  `event.target === dialog`. Focus restored to the opener on close. Card→modal is **event delegation
  on `#schedule-root`** (click + Enter/Space), surviving list re-renders. `openMatchModal(matchId)`
  is the public API for every view.
- **Match stats in modal:** optional `stats` field per game in `results.json`
  (`{ possession, shots, cards }`, home/away following `homeTeam`/`awayTeam`; possession %, total
  shots, yellow cards). Renders real stats when present, else the `—` placeholder + `modal.statsSoon`
  note. Adding stats to more games = edit `results.json` only.

### Hero — hybrid clock+JSON (`app.js`)
- The home hero advances by the **clock**, not only by the JSON. **`matchState(match, result, now)`**
  (pure, **exported**, reused by the schedule occurrence filter): `over` if `status==='finished'`
  **OR** `now ≥ kickoff + window`; `live` if `status==='live'` **OR** `now ≥ kickoff`; else
  `upcoming`. **JSON always wins** (finished/live force); the clock only advances when JSON lags.
  Window: `GROUP_WINDOW_MS = 2h` for `Group*`, else `KO_WINDOW_MS = 3h`.
- **`findFeaturedMatches(now)`** picks the earliest non-`over` match and returns **all** sharing that
  exact kickoff → the hero stacks simultaneous group-final matches (last round = 12 pairs, always 2);
  1-match render is DOM-identical to before. One persistent **1s `heroTick`**; signature
  `"id:state"` (joined for the set) → full `renderHero()` on change, else just `updateCountdown()`.
  `renderHero` is idempotent and re-arms the timer (`if (heroTimer) return`).
- **Hero resolves teams via `resolveBracketTeams(match)`** (not raw `match.homeTeam`), so knockout
  featured matches show real teams/flags once resolved and a placeholder label otherwise — same path as
  schedule cards/modal. `heroTeamHTML(slot)` takes a `{team,label}` slot. **Bug fixed 2026-06-28:** the
  hero previously read `match.homeTeam/awayTeam` directly; harmless during the group stage (those fields
  exist) but the moment the next match became an R32 game (ids 73+, which carry only `bracketRef`) the
  home hero showed "A definir vs A definir". Watch for this class of bug anywhere that reads
  `match.homeTeam` raw instead of resolving.
- Live score shown only if `result.homeScore/awayScore` are non-null; no elapsed-time clock
  (would be inaccurate on a static site). Badge "Bola rolando!" = key `hero.inProgress` (renamed from
  `hero.kickoff`); `hero.live` still used by schedule/modal. **Scope: hero only** — Matches/Modal/
  Bracket live badges stay JSON-`status`-driven (small transient inconsistency accepted). When the
  Final goes `over`, the hero is empty (post-Cup home state is a TODO).

### Live data refresh — poll `results.json` without F5 (2026-06-16, Option A⁺)
- The data is **not live** — it's a manual push after each match. So poll is **fixed**
  (`POLL_INTERVAL_MS = 90s`), not state-based. `startResultsPolling()` (called at the end of
  `init()`, after views register listeners) arms one `setInterval` (`if (pollTimer) return`).
  `pollResults()` fetches `data/results.json?t=${Date.now()}` with `cache:'no-store'`. (As of
  2026-06-18 the initial `loadData()` fetch also uses `?t=Date.now()`; the old hand-bumped
  `?v=DATA_VERSION` cache-buster was removed — see Cache-busting runbook.)
- **Signature = full response text** (catches score corrections, `stats` backfill, penalties — a
  finished-count signature would miss them). On change: rewrite `data.results` **and rebuild
  `data.resultByMatchId`** (the derived map), `invalidateBracket()`, dispatch `datachange`.
- **3 reinforcements over plain fixed poll:** (1) Page Visibility — interval no-ops when
  `document.hidden`; `visibilitychange` does an immediate fetch on return. (2) **Stop at the end** —
  `tournamentOver()` checks `FINAL`'s JSON `status==='finished'` (not clock-`over`, which would stop
  3h after kickoff before the score lands) → `stopResultsPolling()`. (3) Content signature (above).
- **`bracket-config.json` piggybacks the change event:** the poll fetches only `results.json` each
  tick, but on a detected change it **also refetches `bracket-config.json`** the same cycle
  (`data.bracketConfig`) — the one-time 3rd-place fill ships in the same push as a results change, so
  no per-tick config polling, but the open tab still gets the new `thirdPlaceAssignment` without F5.
- **Fan-out:** every view has a `datachange` listener (`app.js`→`renderHome`,
  `schedule.js`→`renderList`, `groups.js`→`render`, `bracket.js`→`render`, `stats.js`→rebuild model).
  Not handled (accepted, rare changes): open modal doesn't auto-update; re-render during drag/typing.

### Performance & responsive/a11y
- **No `backdrop-filter` on repeated cards** — `.match-card` overrides `.glass` blur (huge paint ×
  104 cards). Same rule for any future card grid.
- **Fixed gradient lives on `body::before` (position:fixed)**, not `background-attachment:fixed`
  (avoids repainting the background on scroll).
- **Breakpoints:** ≤767 (tight; bracket `--node-w:168px`/gap 36px — stub offsets stay at gap/2),
  768–1100 (two-band header), **1100+** (single-row header; the flip moved 768→1100, see header
  pattern), 1440+ (`.container` widens to 1360px).
- WAI-ARIA tabs: roving tabindex + Arrow/Home/End in `initTabs()`, focus follows activation.
  Dialogs get `aria-label` at open; schedule count `aria-live="polite"`; countdown `role="timer"`.
  Entry animations (panel fade, card stagger) all gated by `prefers-reduced-motion`.

---

## Gotchas

1. **`fetch()` of JSON fails on `file://`** — always serve via `python -m http.server` (Claude
   Preview `worldcup2026`, port 8126). Symptom: blank app + CORS errors.
2. **GitHub Pages / Hostinger serve under a subpath** — use **relative paths** everywhere
   (`data/matches.json`, `assets/...`); root-absolute (`/data/...`) 404s in production.
3. **`.ics` requires CRLF line endings** (`calendar.js`) — RFC 5545 mandates `\r\n`; some calendar
   apps silently reject `\n`.
4. **Third-place slots are `null` until filled** (`bracket-config.json.thirdPlaceAssignment`) —
   `resolveBracketTeams()` must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever
   a slot is `null` or its group isn't finished. Symptom if forgotten: crash / "undefined" in R32.
5. **Stale JS modules in the dev browser** — `python -m http.server` sends no cache headers, so
   browsers heuristically cache ES modules. Hard-reload via `Promise.all(files.map(f => fetch(f,
   {cache:'reload'}))) → location.reload()`, or DevTools hard reload.
6. **`setPointerCapture` on pointerdown kills element clicks** (`bracket.js`) — capturing retargets
   the eventual `click`, so delegation never matches → modal/sim clicks die. Capture only after the
   >5px drag threshold, in `pointermove`, try/catch. **Verify click flows with `preview_click`
   (trusted input), not `element.click()`.**
7. **Claude Preview screenshots can hang** (tooling, not app) — `preview_eval` keeps working;
   `preview_stop` + `preview_start` recovers. Verify state via `preview_eval` before suspecting the app.
8. **Claude Preview: resize beyond the native window (~791 CSS px) breaks clicks/screenshots** —
   viewport emulation desyncs the capture surface. At emulated widths > native, navigate via
   `preview_eval` + `navigateTo()` and verify geometry via eval/inspect; trust screenshots only at
   widths ≤ native. `preview_resize preset: desktop` resets it.
9. **`aspect-ratio` + `min-height` can transfer size INTO width** (bracket wallchart, 2026-07-03) —
   on a box with `width: auto`, a violated `min-height` transfers back through the ratio and widens
   the element past its container (on mobile the 220px min became 585px of page overflow). Fix: give
   the box a definite width (`width: 100%`); then the ratio only drives height and min/max-height
   clamp it without transfer.

---

## Operational Runbooks

### Daily data refresh
Follow **`how-refresh-data.md`** (project root) before touching any `data/*.json`. In short: edit
`data/results.json` (scores/status, two-source rule, `penalties` only on knockout ids 73–104) →
verify in preview → commit (two-commit convention) → push (user's go) → deploy.
Frozen files (never edit): `stadiums/teams/groups/bracket-config.round32/assets/code`.
`how-update.md` stays as the schema reference for the (completed) mock→real migration.

### `thirdPlaceAssignment` (one-time, after the group stage ~Jun 27–28)
When all 72 group matches are `finished`, fill `bracket-config.json.thirdPlaceAssignment`
(slot → group **LETTER**, per FIFA's published allocation — never derive it yourself). Each group
letter appears in **at most one** slot; unfilled slots stay `null`:

| Slot | Feeds (FIFA match) | Allowed groups |
|---|---|---|
| 1 | M74 (vs Winner E) | A/B/C/D/F |
| 2 | M77 (vs Winner I) | C/D/F/G/H |
| 3 | M81 (vs Winner D) | B/E/F/I/J |
| 4 | M82 (vs Winner G) | A/E/H/I/J |
| 5 | M79 (vs Winner A) | C/E/F/H/I |
| 6 | M80 (vs Winner L) | E/H/I/J/K |
| 7 | M85 (vs Winner B) | E/F/G/I/J |
| 8 | M87 (vs Winner K) | D/E/I/J/L |

### Cache-busting (2026-06-18: DATA_VERSION removed)
`app.js` `loadData()` appends `?t=${Date.now()}` to every `data/*.json` fetch — same scheme the
live-refresh poll already used. **There is no `DATA_VERSION` constant to bump anymore** (removed
2026-06-18); every load gets a unique URL, so Hostinger can never serve a stale `results.json` and
the daily refresh has zero cache step. ~~Previously appended `?v=${DATA_VERSION}` (a hand-bumped
`YYYY-MM-DD-revN` constant) — retired because the manual bump was easy to forget and `Date.now()`
guarantees freshness.~~ Note: **JS/CSS are not versioned** (no build step) — on Hostinger returning
visitors may serve stale code until their browser re-fetches; new visitors / hard-refresh see it at
once. Accepted.

### App version (footer)
Single source of truth: **`assets/js/i18n.js` line 9** — `const APP_VERSION = 'v1.0.X'`. Auto-shown
in both EN and PT footers via `t('footer.note')`. Bump after a notable ship (new section, major
bugfix, schema change, deploy). Commit e.g. `refactor(footer): bump version to vX.Y.Z`.

### Commit convention (standardized 2026-06-15)
Every `/update-worldcup` run = **two commits** (full spec in `how-refresh-data.md`):
1. **Data commit** (`results.json`, + `bracket-config.json` on the 3rd-place day):
   - 1 game → `data: update DD/MM/YYYY HH:MM HOMExAWAY HxA`
   - N games → `data: update DD/MM/YYYY — N jogos` + one body line per game.
   - Penalties (knockout only): suffix `(pen HxA)`.
2. **Docs commit:** `docs: log daily refresh DD/MM/YYYY` (`.agents/` + TODO).

Rules: `DD/MM/YYYY` + `HH:MM` are the match's **UTC** kickoff (as in `matches.json`); codes = 3
uppercase letters; separator lowercase `x`. `.agents/` is excluded from the FTP deploy → keeping it
a separate commit keeps the data commit's diff clean.

### Deploy — Hostinger via FTP (GitHub Actions, 2026-06-14)
- `.github/workflows/deploy.yml`: every `push` to `master` (or `workflow_dispatch`) deploys via
  `SamKirkland/FTP-Deploy-Action@v4.3.5` (`protocol: ftps`, `port: 21`, `local-dir: ./`,
  `server-dir: worldcup2026/`).
- **origin** = `https://github.com/LucasKalil-Programador/world-2026-hub.git` (branch `master`).
  Push via Windows credential manager (**gh CLI is NOT installed** on this machine).
- **Secrets** (repo → Settings → Secrets → Actions): `FTP_SERVER`, `FTP_USERNAME`, `FTP_PASSWORD`
  (from Hostinger hPanel). Without them the workflow fails.
- **Gotcha:** the Hostinger FTP account logs in **already inside `public_html`**, so `server-dir` is
  relative to it — do **not** prefix `public_html/` (causes `public_html/public_html/...`). Final
  path: `public_html/worldcup2026/`. If FTPS is rejected, switch `protocol` to `ftp`.
- `exclude` removes `.git*`, `.github/`, `.agents/`, **`docs/`**, `README.md`, **`DEVELOPMENT.md`**,
  `how-*.md`, `*-en.md` specs — only `index.html` + `assets/` + `data/` reach the site. New `data/` /
  `manifest.json` / `assets/icons/` files **are** deployed. Incremental sync state
  (`.ftp-deploy-sync-state.json`) lives only on the server — don't commit it.

### Real-data migration (DONE 2026-06-12)
All 6 `data/*.json` hold real WC2026 data (sources: Wikipedia per-group + knockout articles,
cross-checked vs ESPN/FOX/olympics.com). **Stadiums trimmed 30 → 16**; cities use FIFA host-city
names ("New York/New Jersey", "San Francisco Bay Area", "Boston") — `matches.json` and
`stadiums.json` must match exactly. **bracket-config app-order ↔ FIFA mapping:** R32-1..16 = FIFA
matches 74, 77, 73, 75, 83, 84, 81, 82, 76, 78, 79, 80, 86, 88, 85, 87 (so the app's sequential
pairing reproduces the official R16/QF/SF progression). Re-verify near Jul 6: **match 94** (R16,
Lumen Field) kickoff was single-source (Wikipedia 17:00 PDT vs an ESPN summary implying 14:00 PDT).

---

## Stats Screen (`feature/stats-final-screen`)

Full post-Cup stats screen built from **`.agents/stats-screen-plan.md`** (stages A–J). The pure-UI
build (**A–D + F**, E skipped) **+ J round 1 polish** was **merged to `master` 2026-06-17** and is
live. `master` keeps the partial screen + daily refreshes. Live sub-nav chips: **Overview · Teams ·
Records · Comparator**. Data-layer stages (G/H/I) + a second J polish remain for near/after the Cup.

### Plan & first-order requirement
Plan generated 2026-06-14 via a 5-sub-agent workflow; scope = **4 data layers** (✅ existing · 🟡🧩
cheap additions · 🔴 player data · 📝 editorial). **First-order requirement — graceful degradation:**
when a datum is missing, the UI must not break **nor reveal to the end user that anything is missing**
— no `—`, no empty cards, no "coming soon". A datum/section renders only when complete enough to be
authoritative; otherwise it is **removed from the DOM** (not hidden). Sub-nav chips of empty sections
disappear too; `loadData()` tolerates a missing optional file (empty default, not an exception).

### Stage A — degradation engine + scaffolding
- **Fault-tolerant `loadData()`:** the 6 core files still **throw** on failure (fatal); 6 optional
  layers (`players`, `player-events`, `awards`, `keeper-stats`, `curiosities`, `all-time-baselines`)
  load via `loadOptional(name, fallback)` → absent/404 returns the empty default **silently**, warns
  only on a present-but-malformed file. Core + optional fetch concurrently.
- **Section-gating — `SECTIONS` registry in `stats.js`:** each section `{ id, navKey,
  available(model), body(model) }` renders (and shows its chip) only when `available` holds; else it
  is **omitted from the DOM entirely** and the nav never points at emptiness.
- **Sticky scrollspy sub-nav:** hero + `<nav.stats-subnav>` (anchor chips) + one section per
  available section + footer. Chips are `<a href="#stats-{id}">` but **`preventDefault` +
  `scrollIntoView`** — they **NEVER set `location.hash`** (the tab router listens on `hashchange`; a
  real `#stats-teams` would route to an unknown tab → bounce to Home). Scrollspy is **position-based**
  (rAF-throttled `scroll` reading `getBoundingClientRect`) + an explicit "at page bottom → last
  section" rule (an IntersectionObserver band left a short final section unlit).
- **`--header-h` CSS var** kept live by `trackHeaderHeight()` (`ResizeObserver` on the variable-height
  sticky header). Sub-nav sticks at `top: var(--header-h)`; sections use `scroll-margin-top`.
- **Media fallback (§0.3):** `flagImg(team,w,h)` emits the flag with `data-monogram="<id>"`; a
  one-time capture-phase `error` listener replaces a broken flag with a 3-letter `<span.flag-fallback>`
  — never a broken-image icon.

### Stage B — verdict hero + goals-by-round
- `heroHTML()` → `model.verdict ? verdictHeroHTML() : aggregateHeroHTML()`. The verdict hero (champion
  + 2/3/4 podium, shared count-up tiles) is **gated on the REAL final:** `computeVerdict()` reads
  `getBracketTree().nodesByRef.get('FINAL')` and returns `null` unless
  `status==='finished' && !simulated && winner` (a user's simulated champion never leaks). Falls back
  to the aggregate "in progress" hero until the final is really finished.
- **Goals-by-round chart** (Overview): group stage split into 3 matchdays (`computeGroupMatchdays`:
  sort each group's 6 fixtures by kickoff, chunk into pairs — `matches.json` has no matchday field)
  plus each knockout round. Hidden until ≥2 rounds have data.

### Stage C — final ranking 1–48, favorites, team records
- **Canonical ranking 1–48 (`assignRanks` → `computeRankTiers`):** primary key is the deepest stage
  **reached** from REAL knockout results (champion 0 → … → group 7), then points → GD → GF → id.
  **Real results only** (same `!simulated && finished` gate as the verdict). During groups everyone is
  tier 7 → it's the global points table; post-knockout the champion is #1 even with fewer points.
- **`#` column = canonical rank AND the default sortable header.** The `#` cell always shows the
  canonical rank regardless of active sort; non-rank sorts fall back to `a.rank - b.rank`.
- **Favorite-team row highlight (gold):** `row-fav` when `getFavorites()` includes the team;
  `favchange` re-renders the table only (favorites aren't in the model). Highlight-only, no stars.
- **Team record cards (Teams):** longest win streak (≥2, hidden below) + champion's path (gated on
  verdict). `stats.js` imports `getFavorites` (storage.js) + `openMatchModal` (modal.js).

### Stage D — Records section + format-48 debuts
- `records` section is **always available** (`body: recordsSectionHTML`). Sub-nav = Overview · Teams ·
  Records. **Match-record cards live here:** biggest win (margin) + highest-scoring match (combined
  goals); high-score card **deduped** when it's the same match as biggest win. (`biggestWin` moved out
  of Teams into Records for a clean C/D split.)
- **"Format debuts" band:** firsts of the 48-team era (48 teams, 104 matches, 12 groups, "Round of 32"
  via `translatePhase`, 8 best thirds advance, first 48-team champion — lights up post-final from
  `model.verdict`). Counts come from `getData()`/model, not hardcoded.

### Stage E — SKIPPED (Option B, 2026-06-17)
The in-tab 104-match results archive will **not** be built. The Matches tab (`schedule.js`) already
lists all 104 with filters/search/sort/occurrence/"My matches"/modal — an in-tab archive would
duplicate it. The footer keeps a **"See all matches →"** link (`#stats-see-matches` →
`navigateTo('matches')`). The `archive` entry stays `available:()=>false` / `body:()=>''` — a dormant
slot, **don't delete the registry line**. If revisited, the lighter "phase-accordion, results-only"
variant (Option C) was the recommended shape.

### Stage F — team comparator
- `comparator` section, `available:(m)=>m.finishedCount > 0`. Two `<select>`s (alphabetical, 48
  teams) default to the **top-2 ranked**; choice survives langchange (module-level `cmpA`/`cmpB`). On
  change, only the bars panel re-renders. **Diverging mirrored bars** scaled to `max(a,b,1)`; higher
  side's number gold. Metrics `CMP_METRICS` are all non-negative (P, W, GF, GA, CS, Pts — **GD
  excluded**, it can be negative). `cmp-grow` scaleX animation, off under `prefers-reduced-motion`.
- **Players side deferred to Stage H** (graceful degradation, not the plan's literal Teams/Players
  toggle — a disabled toggle would be a visible dead control). Teams comparator only for now.

### Stage J round 1 — release polish + merge to master (2026-06-17)
Polish over A–F: i18n audit (no hardcoded strings), a11y (sections `aria-label`led + `tabindex=-1`,
table caption + sort buttons + `aria-sort`, sub-nav is a `<nav>`), reduced-motion gating, cross-tab
regression — **no code fixes were needed**. README got a Stats bullet. **Deferred to the actual
deploy:** the Lighthouse run (the once-deferred final `DATA_VERSION` bump is moot — `DATA_VERSION`
was removed 2026-06-18). **Merge sequence:** merge latest
`master`→branch (resolve conflicts on the branch, never on master), re-verify, then `master ← branch
--no-ff`. Pushing to origin (which triggers the deploy) is the user's explicit final go.

### Sub-nav polish — inner track + edge fades + spy-suppress (2026-06-17, on master)
- **Edge fades** mirror the header tabs: chips live in an inner `.stats-subnav-track` (the scroll
  container); the fade `mask-image` is on the **track**, so the pill's background/rounded ends stay
  crisp. `.stats-subnav` is `overflow:hidden`; `updateSubnavFades(nav)` toggles `.fade-left/-right`
  from the **track's** scroll metrics. All sub-nav scroll JS targets the track, not the nav.
- **Scrollspy "jump" on chip click fixed:** a chip click sets `suppressSpyUntil = Date.now()+700`
  (0 under reduced-motion) and `updateSpy()` early-returns while suppressed, so the clicked chip owns
  the active state until the smooth scroll settles.

### Leader cards — tied-team carousel (2026-06-19)
The Team-statistics "leader" cards (Best attack / Best defense / Most clean sheets) became a
**config-driven set of 6** and each now **rotates through ALL teams tied on its headline metric**
(was: only the single top team). New cards: **Most wins**, **Most goals conceded**, **Best goal
difference** (GD value shows a `+` sign when positive).
- **Tie grouping is by the headline metric ALONE** (decided via /grill-me) — `gf` / `ga` / `cleanSheets`
  / `won` / `ga` / `gd` — *not* the secondary tiebreakers, so e.g. all teams level on goals-for share
  one card. Within the group the existing `cmp` (with tiebreakers) sets order, so the **first team
  shown is unchanged** from before. Driven by the `LEADER_CARDS` array in `stats.js`; `computeLeaders`
  now returns `[{ id, labelKey, metric, group: Row[] }]` (was an object of single rows).
- **Carousel UX:** auto-advance every `ROTATE_MS = 3500`; **pauses on hover/focus**, **disabled under
  `prefers-reduced-motion`** (arrows still work). ◀▶ arrows are **circular** (wrap-around); a manual
  click effectively restarts the cadence (it resumes fresh on pointer/focus-leave). Indicator =
  **dots** (one per tied team, active = gold) up to `DOTS_MAX = 8`; **above 8 the dots become an
  `"i / n"` counter** (keeps the card compact — e.g. early-Cup Best defense routinely has 8 teams at
  GA 0). A **1-team group renders the plain static card, identical to before** (no arrows/dots/timer).
- **Timer lifecycle (cf. gotcha #6):** `setupLeaderCarousels(root)` runs at the end of `render()`;
  intervals are tracked in module-level `leaderTimers` and **cleared at the top of `render()`**
  (`clearLeaderTimers()`) so a `langchange`/`datachange` re-render never leaves a timer firing on
  detached DOM. `favchange` does not touch these cards, so their carousels survive it untouched. Only
  the flag+name swap on rotate — the big value is shared by the whole tied group, so it never changes.
- i18n keys added (EN+PT): `stats.mostWins`, `stats.mostConceded`, `stats.bestGoalDiff`,
  `stats.leaderPrev`, `stats.leaderNext`. CSS: `.leader-stage/.leader-nav/.leader-dots/.leader-dot/
  .leader-counter` in `stats.css`.

### Partial stats tab built during the Cup (foundation, 2026-06-14)
The 6th `stats` tab was first shipped incrementally as the evolving foundation of the post-Cup plan
(same tab/module; post-Cup sections "light up" later). Files: `assets/js/stats.js` +
`assets/css/stats.css`. Philosophy (decided via /grill-me): current-to-date aggregates, **only
`status==='finished'`** (consistent with `computeStandings`); "X of 104" is framing, not a gap.
`aggregateTeams()` is its own tournament-wide aggregation (group + knockout); optional per-game
`stats` enters with per-game gating. Memoized model (`let model`), re-render of labels on `langchange`.

---

## Patterns & How-tos

### How to add a UI label
1. Add the key to **both** `en` and `pt` dicts in `assets/js/i18n.js`.
2. Use `t("key")` at the render site — never hardcode UI text in HTML/JS. (Data values — team/stadium
   names, cities — come from JSON and are **not** translated.)

### How to add a new localStorage preference
1. Extend the `wc2026_prefs` shape (document the new field here).
2. Read/write only via `storage.js` `get`/`set`.

### Tooltips + mobile legend (2026-06-14)
- Table-header abbreviations (Stats team table + the 12 Groups tables) get a **custom glass tooltip**
  (not native `title`). `initTooltips()` in `app.js`: a single `position:fixed` `.app-tooltip` via
  event delegation on `document` (so it survives re-renders and is never clipped by `overflow-x:auto`
  containers); clamps to viewport, flips below if it doesn't fit above.
- **Give a header a tooltip:** add `has-tip` + `data-tip="<text>"` + `aria-label="<abbr> — <text>"`;
  texts in `i18n.js` namespace `tip.*` (EN/PT), reused by both tables.
- **Mobile legend:** `<p class="stats-legend">` (`display:none` desktop, `flex` ≤600px) — covers
  touch where hover doesn't fire. `legendHTML()` in `stats.js` / `groups.js`. CSS lives in
  `stats.css` (loaded globally, so it also applies to Groups).

### How to add a stadium SVG
Follow the trimmed structure of the 16 existing ones (chrome stripped 2026-06-14 — `stadiums.js`
renders name/city/capacity as HTML, so the SVG must **not** duplicate them): `<svg viewBox="...">`
(**no** `width`/`height`) → `<defs><style>` with only the
`struct/thin/hair/concrete/stands/canopy/void/pitch/pline/acc/accs/green/ribs/louver` classes +
`frit` pattern → a single `<g>` illustration cropped tightly (~10px padding). Aim for a viewBox aspect
ratio near **4:3** (~1.2–1.3) to match `.stadium-img { aspect-ratio: 4/3; object-fit: cover }` in
`style.css` (4:3, not 16:9 — the SVGs' natural ratios are ~1.07–1.32, and 16:9 cropped ~28% of
height, slicing the illustrations). The white tower shapes on some cards (`class="void"`) are the
press-box/scoreboard — intentional, don't remove.

### PWA — installable (Tier 1, 2026-06-16)
Scope shipped = **Tier 1** (manifest + icons + meta tags) — meets every install criterion; **no JS
changed**. Files: `manifest.json` (root), `favicon.ico` (root), `assets/icons/` (icon.svg master +
192/512 PNGs any + maskable + apple-touch 180 + favicon-16/32). `index.html` `<head>` got the PWA
block (manifest link, `<meta theme-color #081421>`, favicons, apple-mobile-web-app-* meta). Manifest:
`name "World Cup 2026 Hub"` / `short_name "WC 2026 Hub"`, `display:standalone`, colors `#081421`
(`--bg-primary`), `start_url:"."` + `scope:"./"` **relative** (gotcha #2). Named `manifest.json` (not
`.webmanifest`) for safe MIME on Hostinger. **To change the icon:** edit the SVG(s) and re-run the
ImageMagick rasterize commands (`magick -background none icon.svg -resize NxN ...`; favicon.ico =
16+32). **Tier 2 (service worker / offline) is deliberately deferred** — see `issues.md`; if built it
**must exclude `data/*.json`** from the cache or it breaks the live-refresh poll.

### Responsive header — 2 bands + scrollable tabs (2026-06-15)
Single-row flip (`.tabs { flex:0 1 auto; margin-inline:auto }`) moved from `@media (min-width:768px)`
→ **`@media (min-width:1100px)`** (single row needs ~950px of content; below that the controls
overflowed). Below 1100px: **two stable bands** (band 1 = logo + controls, band 2 = scrollable tabs).
Edge fades via `mask-image` toggled by `updateTabFades()`; active tab kept visible via
`scrollActiveTabIntoView()` (uses `scrollLeft`, **not** `scrollIntoView`, to avoid scrolling the
page). The time button collapses to a 🕐 icon at ≤420px (a11y intact via `data-i18n-aria`). This
supersedes the old "768–1439 single-row header" note.

### Docs — README showcase + DEVELOPMENT.md split (2026-07-04)
The root README was reframed (via /grill-me) from a dev/maintenance guide into a **non-technical
showcase** (English): tagline, shields.io badges, prominent live-demo link
(**https://lucaskalil.com/worldcup2026** — the public URL; not previously recorded anywhere),
per-page **screenshot gallery** (Home/Matches/Groups/Knockout/Stadiums/Stats), and a plain-language
"Under the hood" section. All the old technical content (run locally, project structure, JSON
maintenance, local storage, deploy, acceptance criteria, roadmap) moved to a new **`DEVELOPMENT.md`**;
the README's stale "mock data / GitHub Pages" framing was corrected to real-data + the real Hostinger
deploy. Screenshots live in **`docs/screenshots/*.png`**, captured with **headless Edge**
(`msedge --headless=new --window-size=1366,H --virtual-time-budget=6000 --lang=en-US --screenshot`,
`--lang=en-US` forces the EN UI; served from Claude Preview `worldcup2026` on :8126) — repeat that to
refresh them. **Deploy exclude updated** to drop `docs/` + `DEVELOPMENT.md` (docs never ship to the
live site — see Deploy runbook). No app-code/version change (`APP_VERSION` untouched; docs are
excluded from deploy).

### How to record a decision (after finishing a unit of work)
1. Tick the item in `.agents/TODO.md`.
2. Append the new decision/gotcha/pattern to the right section here (don't rewrite existing entries;
   don't add dated refresh logs — those go in git + the Current State rolling window).
3. Rewrite `project-map.md` if structure/functions changed.

---

## Current State

**Updated 2026-07-03.** **Bracket redesign Steps 1–3 shipped on master (not yet pushed):** the
Knockout tab has 3 switchable views — center-out wallchart (desktop default), radial "orbit" (flag
tokens per the user's reference image), rounds pager (mobile default, button-only navigation, ≤2
columns). See Architecture → "Bracket redesign" (both entries). Pending: Step 4 champion
celebration + polish pass + version bump to v1.1.0.
Data: **R32 underway** — group stage COMPLETE (1–72) + R32 matches **73
(RSA 0–1 CAN)**, **74 (GER 1–1 PAR, PAR 4–3 pens)**, **75 (NED 1–1 MAR, MAR 3–2 pens)**, **76
(BRA 2–1 JPN)**, **77 (FRA 3–0 SWE)**, **78 (CIV 1–2 NOR)**, **79 (MEX 2–0 ECU)**, **80
(ENG 2–1 COD)**, **81 (USA 2–0 BIH)** and **82 (BEL 3–2 SEN, AET)** finished (82/104 total);
remaining R32 ids 83–88 are next.
`thirdPlaceAssignment` **FILLED** (8 best thirds → R32 — see the rolling refresh list below).
Cache-busting is now automatic (`?t=Date.now()`; `DATA_VERSION` removed 2026-06-18). `APP_VERSION = v1.0.3`
(bumped 2026-06-28: hero knockout-resolution fix + best-third ranking table in the Grupos tab). Build: all 12 steps + real-data migration
done; Stats stages A–D + F + J(r1) merged to `master` and live (E skipped). Stats Team-statistics
leader cards now rotate through tied teams + 3 new metric cards (Most wins / Most goals conceded /
Best goal difference) — see Stats Screen → "Leader cards — tied-team carousel".

### Recent refreshes (rolling — keep the last 3, prune older; full detail in git)
- **2026-07-02** — **R32 ids 81 & 82.** Match 81 (R32-7, USA D1 × BIH 3rd-B): **USA 2–0 BIH** —
  Balogun 45' (his 3rd of the tournament) then sent off 64' for a reckless tackle on Muharemović,
  Tillman sealed it with an 82' direct free-kick; USA's first WC knockout win since 2002, playing the
  final ~30min with 10 men — 2-source+ confirmed ESPN(gid 760494)/CBS/NBC Bay Area/NPR/CNN/FOX/US
  Soccer/Opta Analyst. Stats: poss 48/52 (ESPN match box), shots 8/10 (Opta Analyst total-shots —
  cross-checked against ESPN's own SOG-conversion %: 2/8=25% and 3/10=30%, both match exactly). Cards
  1/1 (Balogun red 64' only; BIH's Radeljić yellow 80' — excluded a 2nd BIH yellow shown to head coach
  Barbarez, a bench/technical-area card, not a player card). Regulation, no penalties. Winner
  propagated: USA → R16-4 vs BEL (verified in bracket, matches real-world reporting). Match 82
  (R32-8, BEL G1 × SEN 3rd-I): **BEL 3–2 SEN (AET)** — Diarra 25', Sarr 51' put Senegal 2-0 up; sub
  Lukaku 86' and Tielemans 89' forced extra time, Tielemans converted a 125' penalty (VAR) for the
  latest goal in WC history — 2-source+ confirmed ESPN(gid 760493)/Outlook India/Opta Analyst/CBS/
  FOX/Sofascore. Stats: poss 52/48 (FotMob — ESPN's page didn't expose a possession stat, same gap as
  the 77 FRA-SWE refresh), shots 18/5 (ESPN match box — initial reads were inconsistent/swapped, took
  2 more fetches with explicit home/away attribution to settle; FotMob's conflicting 19/19 discarded
  as the outlier). Cards 1/1 (Mechele BEL yellow 64', Camara SEN yellow 67'; excluded Belgium coach
  Rudi Garcia's dissent yellow and an uncorroborated FotMob claim of a 2nd Camara yellow at 73' — no
  source reports Senegal playing a man down). Decided in extra time by a live-play goal, not a
  shootout — no `penalties` field. Winner propagated: BEL → R16-4 vs USA (verified in bracket). Next
  R32: ids 83–88.
- **2026-07-01 (b)** — **R32 id 80 (R32-12, ENG L1 × COD 3rd-K): ENG 2–1 COD.** Cipenga 7' gave
  Congo an early lead (their first-ever WC knockout appearance); Kane leveled 75' (header) and won
  it 86' (strike), both assists from Gordon — 2-source+ confirmed ESPN(gid 760495)/Olympics.com/
  Fox Sports/englandfootball.com/CBS/NBC/Yahoo. Stats: poss 60/40, shots 9/5 (ESPN match box — a
  Sofascore-derived search snippet suggested 16/7 but that looked like a different attempt-counting
  method, so ESPN's box was used per the authoritative-source rule), cards 1/1 (Bellingham 19' ENG,
  Sadiki ~27' COD, both yellow, no reds — confirmed via Sofascore's card list). Regulation, no
  penalties. Winner propagated: ENG → R16-2 vs MEX (verified in bracket). Next R32: ids 81–88.
- **2026-07-01** — **R32 ids 77 & 79.** Match 77 (R32-2, FRA I1 × SWE F3): **FRA 3–0 SWE** —
  Mbappé 45', 74' (his 6th of the tournament, level with Messi's career WC tally), Barcola 53' — 2-source
  confirmed ESPN(gid 760492)/FIFA match centre/Al Jazeera/FOX/RTE. Stats: poss 61/39 (FotMob; ESPN's
  page didn't expose a possession stat), shots 11/6 (ESPN match box — used over FotMob's much higher
  25/8 figure, which looked like a different attempt-counting method), cards **0/0** — checked ESPN
  boxscore + playbyplay, FOX, Al Jazeera live blog, RTE, Yahoo recap; none reported any booking, so
  treated as a clean game rather than an unconfirmed gap. Regulation, no penalties. Winner propagated:
  FRA → R16-1 vs PAR (verified in bracket). Match 79 (R32-11, MEX A1 × ECU E3): **MEX 2–0 ECU** —
  Quiñones 22', Jiménez 31'; Ecuador's Hincapié sent off 90+5' (covering his mouth during a dispute
  with Giménez, a new directive this tournament) — 2-source confirmed ESPN(gid 760491)/FIFA match
  centre/Al Jazeera/Yahoo/CBS. Stats: poss 43/57, shots 9/7, cards 0/3 (2 yellow + Hincapié's red) —
  all from the ESPN match box, red card cross-confirmed by the Yahoo live blog. Regulation, no
  penalties. Mexico (co-host) reached R16 for the first time in the format; winner propagated to R16-2
  (verified in bracket). Next R32: ids 80–88.

### Pending / next
- **Knockout R32 (ids 73–88) — in progress.** Done: 73 (RSA 0–1 CAN), 74 (GER 1–1 PAR, PAR 4–3 pens),
  75 (NED 1–1 MAR, MAR 3–2 pens), 76 (BRA 2–1 JPN), 77 (FRA 3–0 SWE), 78 (CIV 1–2 NOR), 79 (MEX 2–0 ECU),
  80 (ENG 2–1 COD), 81 (USA 2–0 BIH), 82 (BEL 3–2 SEN AET). Next: ids 83–88. `penalties` apply on ids
  73–104 (KO only — append `"penalties": {home,away}` and keep `homeScore/awayScore` as the 90+30
  score; id 82 was decided in extra time by a live goal, not a shootout, so it carries no `penalties`
  field despite going past 90+30). R16 ids 89–96 from 2026-07-04.
  **Note:** the 75 (NED×MAR) card count is single-source (Sofascore, only Diop 47') — re-confirm if a clean box surfaces.
- **`thirdPlaceAssignment` — DONE (2026-06-28).** All 8 slots filled from FIFA's official combination
  table; bracket verified. No longer pending.
- **Lighthouse > 90** run (needs a deployed URL).
- **Post-Cup home state** — when the Final goes `over` the hero is empty; build a champion/epilogue
  state (likely converges with the Stats screen).
- **Stats Stage G** (Layer-2 cheap data — `cards`→{y,r} migration is breaking for `modal.js` +
  `stats.js`; **schedule LATE**, conflicts with daily `results.json` edits), **Stage H** (players +
  the deferred comparator Teams/Players toggle), **Stage I** (editorial), **Stage J round 2** polish.
- **PWA Tier 2** (service worker + offline) — deferred; must exclude `data/*.json` (see `issues.md`).

### Success metrics
Lighthouse > 90; first render < 2s; total JS < 300KB (74 KB measured at build). Spec §18 acceptance
criteria all checked (README checklist).

### Communication
User communicates in an English/Portuguese mix; docs in English where practical (retained PT passages
kept as written). **Ask before each build step** — never chain into the next without explicit go-ahead.
