world-2026-hub/.agents/project-memory.md

384 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Project Memory — World Cup 2026 Hub
Persistent memory for this project. Read this before any significant change.
---
## Context
Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) — schedule, group standings, interactive knockout bracket with user simulation, stadiums. Hosted on GitHub Pages, all content from `data/*.json`. Started 2026-06-11 from two spec documents; built step-by-step with user approval between steps.
### What this project is
- A **personal/portfolio** piece — visual polish (glassmorphism, animations) is a primary goal, not a nice-to-have.
- 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 this project is **not**
- No backend, no database, no build step, no bundler, no CDN dependencies, no frameworks.
- No automated tests, no linter (explicit spec constraint).
- Not real-data-complete: ships with mock data (fictional teams) to be replaced later.
---
## Priority objectives
1. **Spec compliance** — both spec files define scope; `complement-spec-worldcup2026-en.md` wins on conflict.
2. **Visual quality** — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
3. **Interactive bracket** — hover path highlight, zoom, drag, simulation mode; the centerpiece feature.
4. **Easy maintenance** — real data drop-in via JSON; `bracket-config.json` is the only file edited after group stage.
5. **Performance/accessibility** — Lighthouse > 90, first render < 2s, JS < 300KB, ARIA + keyboard nav.
---
## Technical decisions and rationale
### Stack
- **Vanilla HTML/CSS/JS ES2022+, ES Modules** spec mandate; GitHub Pages serves static files only. Frameworks/bundlers explicitly forbidden.
- **EN/PT-BR UI toggle via `i18n.js`** (user decision 2026-06-11) not in spec; spec UI examples are EN, user wants both. Tiny dict + `t(key)`, persisted in `wc2026_prefs.lang`. Alternative (EN only) rejected by user.
- **`storage.js` pulled forward to step 2** (spec places it in phase 12) prefs (`lang`, `lastTab`) are needed from the base-layout step; building it late would mean refactoring.
### Data model
- **All match times in UTC** in `matches.json`; converted at render via `Intl.DateTimeFormat` (`formatMatchTime`). `.ics` export depends on this.
- **Knockout matches carry `bracketRef` instead of teams** teams resolved at runtime from standings + `bracket-config.json`; rounds after R32 have no config, generated by sequential pairing of winners (indices 0-1 0, 2-3 1, …).
- **Simulation never mutates JSON** overlay stored in `localStorage` `wc2026_simulation`, keyed by bracket match ids (`R32-1`, `QF-2`, `FINAL`, …).
### Mock data design (2026-06-11, step 1)
- **Real country names, fictional results** generated deterministically (seed 2026), script deleted after run; data is now static JSON.
- **State crafted to test both bracket modes:** matchdays 1-2 all `finished`; matchday 3 `finished` for groups AF, `scheduled` for GL (match 61, Group G, is `live`). So R32 slots fed by AF resolve to real teams; GL and all `third` slots show placeholders.
- **Knockout results:** `R32-2` (match 74) finished 1-1 + penalties 4-3; `R32-4` (match 76) finished 2-0. Everything else `scheduled` with `null` scores `results.json` has an entry for **all 104 matches**.
- **Image paths:** `flag`/`image` JSON values are relative to `assets/images/` (e.g. `flags/mex.svg`, `stadiums/azteca.svg`).
- **Opener** (match 1) is MEX at Estadio Azteca 2026-06-11; **final** (match 104) at MetLife 2026-07-19.
### Base layout decisions (2026-06-11, step 2)
- **Hero priority: live > next scheduled** — a live match replaces the countdown with the score + pulse badge. Mock data keeps match 61 permanently `live`, so the countdown only shows if that status is changed (verified working by temporarily setting it to `scheduled`).
- **i18n mechanics:** static HTML uses `data-i18n` / `data-i18n-aria` attributes re-applied by `applyI18n()`; dynamic renders call `t()` and listen for the `langchange` event on `document`. Phases translate via `translatePhase()` (PT: R32 = "16 avos de final").
- **Tab routing:** hash (`#matches`) + `wc2026_prefs.lastTab`, `history.replaceState` to avoid history spam; precedence on load: hash → lastTab → home.
- **Default language:** `navigator.language` startsWith `pt` → PT, else EN; only persisted when the user clicks the toggle.
- **Preview server:** `.claude/launch.json` at `R:\lucas-kalil\Projects\` defines `worldcup2026` (python http.server, port 8126) for the Claude Preview panel.
### Schedule + performance decisions (2026-06-11, step 3)
- **`schedule.js``app.js` circular import is intentional** — `app.js` calls `initSchedule()`, `schedule.js` imports `getData`/`formatMatchTime`/`flagSrc` back. Safe in native ESM because all calls happen after both modules evaluate; keep this pattern for `groups.js`/`bracket.js`/`modal.js`.
- **Filter UX:** toolbar is rebuilt only on init/langchange/clear (state restored programmatically); list re-renders on every filter change. Filter state is in-memory only (not persisted) by design.
- **Knockout cards show "TBD"** until step 7 swaps `teamColumnHTML`'s lookup to `resolveBracketTeams()`.
- **Perf: no `backdrop-filter` on repeated cards** — `.match-card` overrides `.glass` blur (huge paint cost × 104 cards, invisible over the smooth gradient). Same rule applies to any future card grid (stadiums, bracket).
- **Perf: fixed gradient lives on `body::before` (position: fixed)**, not `background-attachment: fixed` (the latter repaints the whole background on scroll).
### Standings decisions (2026-06-11, step 4)
- **Only `status: "finished"` matches count toward standings** — live scores are ignored until full-time (keeps standings stable and bracket resolution deterministic).
- **Tiebreak order:** points → goal difference → goals for → team id alphabetical (stable fallback). Verified against an independent Python computation for groups A and G.
- **`computeStandings()` / `isGroupFinished()` exported from `groups.js`** — bracket.js (step 7) must import these instead of recomputing.
### Stadiums decisions (2026-06-11, step 5)
- **`stadiums.js` module added** — spec §4 has no module for the stadiums view; a dedicated view module keeps the per-view pattern (schedule/groups/bracket) instead of growing `app.js`.
- **Cross-link:** "View matches" on a stadium card calls `setStadiumFilter(name)` (exported by `schedule.js`, resets other filters) + `navigateTo('matches')` (exported by `app.js`).
### Modal decisions (2026-06-11, step 6)
- **Native `<dialog>` + `showModal()`** — focus trap, Esc-to-close and `::backdrop` come free; no custom trap code. Backdrop click detected via `event.target === dialog` (content is in a padded inner div).
- **Focus restore:** opener element saved in `openMatchModal()` and re-focused on `close`.
- **Card → modal wiring is event delegation on `#schedule-root`** (click + Enter/Space keydown), so it survives list re-renders. Cards got `tabindex="0" role="button" aria-label` already in this step.
- **`openMatchModal(matchId)` is the public API** — bracket (step 7) and any future view should call it rather than building their own.
### Bracket decisions (2026-06-11, step 7)
- **Tree model 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** in `bracket.js`; `invalidateBracket()` exists for the simulation overlay (step 9). Results are static per page load, so nothing else invalidates it.
- **`resolveBracketTeams(matchOrRef)`** accepts a match object (group or knockout) or a bracketRef string and always returns `{ home, away }` as `{ team: Team|null, label: string }` — schedule cards, modal, and search/team filters all consume this, so knockout matches become searchable/filterable once resolved.
- **Connector geometry**: all bracket columns share equal height with `flex: 1` slots, so pair children sit at 25%/75% and the next round's node at 50% — pure-CSS connectors (`::before`/`::after` stubs + pair vertical) meet exactly. Column gap 44px = 22px out-stub + 22px in-stub. Breaking the equal-height invariant breaks the lines.
- **Final column** holds champion box (top), FINAL (middle, aligns with SF pair), third-place block (bottom); its champion/third slots suppress the incoming connector stub.
### Bracket interaction decisions (2026-06-11, step 8)
- **"Full path" on hover/focus** = hovered node + its entire feeder subtree + its winner's route to the FINAL; THIRD-PLACE lights both SFs. Non-path nodes dim via `.has-path` on the canvas. Computed from ref arithmetic (`floor(i/2)` up, `2i/2i+1` down), no tree lookup.
- **Zoom = CSS `transform: scale()` on the canvas + a `#bracket-zoom` box sized to `natural × scale`** (transform doesn't affect layout, so the box gives the scroll container the right scrollable area). Pointer-anchored: scroll adjusted so the point under the cursor stays put. Clamped 0.42.
- **Natural canvas size is measured lazily** (`ensureMeasured()`) because the bracket panel can be `hidden` at render time (offsetWidth 0).
- **Pan + pinch via Pointer Events** with `touch-action: none` on the wrap (we own all gestures there; page scroll over the bracket is intentionally captured, like a map widget).
- **Dragclick conflict:** >5px movement sets a flag; a capture-phase click listener on the wrap swallows the click that ends a drag. The flag resets on the next `pointerdown`, so synthetic `el.click()` without pointerdown can be falsely suppressed in tests — dispatch pointerdown/up first.
- **Zoom level survives langchange re-renders** (module-level `view.scale`) but intentionally not reloads (not in prefs).
### Simulation decisions (2026-06-11, step 9)
- **Separation of concerns in the tree:** `decide()` applies only real finished results; `applySimulation()` overlays user picks afterwards and never overrides a real result. Stale entries (winner no longer among the resolved teams) are silently ignored — same validation the prediction import (step 12) will use.
- **Sim UX:** "Simulation" toggle in the bracket toolbar; eligible nodes (both teams resolved, real result still `scheduled`) get dashed blue borders; clicking one opens a small native `<dialog>` picker. An unequal score auto-selects the winner; a draw requires an explicit pick (penalties implied). Empty score defaults to 1-0 for the picked winner.
- **Storage format** is exactly the complement-spec shape: `wc2026_simulation = { "R32-6": { winner: "FRA", score: "2-1" } }` keyed by bracketRef, score oriented home-away.
- **`simchange` custom event** fires after any pick/reset; `schedule.js` listens and re-renders so simulated teams appear on knockout cards too (intentional leak — resolved tree is the single source).
- **Simulated nodes** show a blue "SIM" corner chip and blue scores; the modal shows none of this (it reads real results only).
### Responsive/a11y decisions (2026-06-11, step 10)
- **Breakpoints:** ≤767 (tight spacing, bracket `--node-w: 168px`/gap 36px — connector stub offsets must stay at gap/2, overridden in the same media query), 7681439 (single-row header, centered menu), 1440+ (container widens to 1360px).
- **Tabs follow the WAI-ARIA pattern:** roving tabindex + ArrowLeft/Right/Home/End in `initTabs()`; focus follows activation.
- **Dialogs get `aria-label`** set at open time (match name + phase); schedule count is `aria-live="polite"`; countdown has `role="timer"` + label.
- **Entry animations:** every unhidden `.panel` fades in; card grids (`.match-grid/.groups-grid/.stadiums-grid > *`) slide up with a 45ms stagger on the first 6 children, 260ms for the rest. All killed by `prefers-reduced-motion`.
### Extra features decisions (2026-06-12, step 12 — done before step 11 at user request)
- **Favorites:** single global capture-phase click delegation in `app.js` handles every `.fav-btn` (schedule, groups, modal) and dispatches `favchange`; each view re-renders itself. Stars never trigger the card/modal click (guard via `closest('.fav-btn')`). Bracket shows highlight only (no stars — nodes too small). Favorite involvement = gold left border.
- **`getFavoriteMatches(matches, favorites)`** lives in `bracket.js` (needs `resolveBracketTeams`), imported by `schedule.js` for the "My matches" filter.
- **Time mode:** header `#time-toggle` flips `wc2026_prefs.timeMode` and dispatches `timemodechange`; `formatMatchTime()` already defaulted to the pref, so views just re-render.
- **Challenge:** sim entries for real-finished matches can't be created in the UI (locked) but old ones persist in storage — that's by design: predictions are made while matches are `scheduled`, then scored when results land. Card renders only when ≥1 finished knockout match exists.
- **Share/import:** `?prediction=` is stripped from the URL via `history.replaceState` whether applied or not (prevents re-prompt loops). Declining `confirm()` keeps local picks; unknown refs are rejected wholesale.
- **`.ics`:** RFC 5545 TEXT escaping (`\,` etc.) applied even though the spec template shows raw commas — RFC compliance wins; verified output imports with CRLF-only endings.
- **Custom events now in play:** `langchange`, `simchange`, `favchange`, `timemodechange` — all on `document`; views own their re-renders.
### Build complete (2026-06-12, step 11 — all 12 steps done)
- README is the user-facing manual (run, deploy, JSON maintenance, localStorage keys, acceptance checklist). Keep it in sync when data formats change.
- **Verified at completion:** spec §18 criteria all pass; JS = 74 KB total (budget 300 KB); no root-absolute paths (GitHub Pages safe). **Not yet verified:** Lighthouse > 90 (needs a deployed URL or local Lighthouse run); actual GitHub Pages deploy.
- No commits made yet — repo initialized but empty; commit when the user asks.
### Workflow
- **12-step build plan with approval gates** — user approves each step before the next starts; summary after each step. Plan: `C:\Users\Lucas\.claude\plans\read-r-lucas-kalil-projects-web-worldcup-goofy-meerkat.md`.
- **Git repo, commits only when the user asks.**
---
## Known gotchas
### 1. `fetch()` of JSON fails on `file://`
**Where:** any local testing of `index.html`
**Why:** browsers block `fetch` of local files (CORS/origin rules)
**Symptom if forgotten:** blank app, console CORS errors, wasted debugging
**Solution:** always serve via `python -m http.server` (or any static server) from the project root
### 2. `.ics` requires CRLF line endings
**Where:** `assets/js/calendar.js`
**Why:** RFC 5545 mandates `\r\n` between lines; some calendar apps reject `\n`
**Symptom if forgotten:** exported event silently fails to import in Outlook/Apple Calendar
**Solution:** join VCALENDAR lines with `\r\n` explicitly
### 3. Third-place slots are `null` until defined
**Where:** `data/bracket-config.json``thirdPlaceAssignment`
**Why:** the 8 best third-place teams are only known after the group stage
**Symptom if forgotten:** crash or "undefined" team names in R32 rendering
**Solution:** `resolveBracketTeams()` must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever a slot is `null` or the group isn't finished
### 4. Claude Preview screenshots can hang (tooling, not app)
**Where:** Claude Preview panel during verification
**Why:** the preview window's screenshot pipeline occasionally gets stuck; `preview_eval` keeps working
**Symptom if forgotten:** wasted debugging hunting a nonexistent app freeze
**Solution:** `preview_stop` + `preview_start` recovers it; verify state via `preview_eval` first before suspecting the app
### 5. Stale JS modules in the dev browser
**Where:** any JS edit while previewing via `python -m http.server`
**Why:** the server sends no cache headers, so browsers heuristically cache ES modules; a normal reload can keep serving old code
**Symptom if forgotten:** "module does not provide an export" errors or old behavior despite correct code on disk
**Solution:** `Promise.all(files.map(f => fetch(f, { cache: 'reload' })))` then `location.reload()`, or DevTools hard reload
### 6. `setPointerCapture` on pointerdown kills element clicks
**Where:** `assets/js/bracket.js` drag/pan handling on `#bracket-wrap`
**Why:** capturing a pointer retargets the eventual `click` event to the capture element, so delegation via `event.target.closest('.bracket-match')` never matches — modal and simulation clicks silently die
**Symptom if forgotten:** bracket nodes unclickable with real input while synthetic `el.click()` tests still pass
**Solution:** capture only after the drag threshold (>5px) is exceeded, inside `pointermove`, wrapped in try/catch. **Always verify click flows with `preview_click` (trusted input), not `element.click()`.**
### 7. GitHub Pages serves under a subpath
**Where:** all asset/data URLs in `index.html` and JS `fetch` calls
**Why:** project pages live at `https://<user>.github.io/<repo>/`, so root-absolute paths (`/data/...`) break
**Symptom if forgotten:** works locally, 404s on GitHub Pages
**Solution:** use relative paths (`data/matches.json`, `assets/...`) everywhere
### 8. Claude Preview: resize beyond the native window breaks clicks/screenshots
**Where:** Claude Preview panel, `preview_resize` wider than the native window (~791 CSS px)
**Why:** viewport emulation desyncs the capture/click surface from the page (screenshots show the page squeezed in the left half; `preview_click` lands on wrong coordinates and silently does nothing)
**Symptom if forgotten:** "clicks don't work" / half-black screenshots at desktop widths, wasted debugging — the app itself is fine (`preview_eval` geometry confirms)
**Solution:** at emulated widths > native, navigate via `preview_eval` + exported `navigateTo()` and verify geometry via eval/inspect; trust screenshots only at widths ≤ native. `preview_resize preset: desktop` resets to native and fixes clicks.
---
## Patterns for future changes
### Match stats no modal (2026-06-14)
- **Campo opcional `stats` em `results.json`** por jogo: `{ possession: {home,away}, shots: {home,away}, cards: {home,away} }` (`home`/`away` seguem `homeTeam`/`awayTeam` de `matches.json`; valores = posse em %, finalizações totais, cartões amarelos). Primeiro preenchido no jogo 6 (BRA 11 MAR) como teste do deploy: posse 51/49, finalizações 12/14, cartões 2/0 (fontes ESPN/Opta/Sofascore).
- **`modal.js` renderiza stats reais quando `result.stats` existe**; senão mantém o placeholder com `—` + nota `modal.statsSoon` (backward-compatível, verificado nos jogos 6 com stats e 4 sem). `statRow(home, label, away)` agora recebe valores; antes só o label.
- Reusa as chaves i18n já existentes: `modal.possession` / `modal.shots` / `modal.cards`. Adicionar stats a mais jogos = só editar `results.json`, nenhum código muda.
### Hero cronômetro inteligente — regra híbrida relógio+JSON (2026-06-15)
- **O hero da home (`assets/js/app.js`) deixou de depender só do JSON pra trocar de estado.** Antes: cronômetro zerava → mostrava `hero.kickoff` ("Bola rolando!") congelado **até** o `results.json` mudar. Agora o estado avança pelo **relógio** mesmo sem update do JSON.
- **`matchState(match, result, now)`** (função pura) é a fonte do estado: `over` se `status==='finished'` **OU** `now >= kickoff + janela`; `live` se `status==='live'` **OU** `now >= kickoff`; senão `upcoming`. O JSON sempre vence (finished/live forçam); o relógio só "chuta" o avanço quando o JSON está atrasado.
- **Janela variável** (`matchWindowMs`): `GROUP_WINDOW_MS = 2h` se `match.phase.startsWith('Group')`, senão `KO_WINDOW_MS = 3h` (mata-mata pode ir a prorrogação+pênaltis). Constantes no topo da seção hero.
- **`findFeaturedMatch(now)`** agora pega o jogo **não-`over`** de kickoff mais cedo, desempate por `id` (igual `schedule.js`). Substituiu o "pega o `live` do JSON, senão o `scheduled` mais cedo".
- **Motor de 1 interval persistente** (`heroTimer`/`startHeroClock`/`heroTick`): a cada tick computa `heroSignature` = `"${id}:${estado}"`; **mudou**`renderHero()` completo (cobre as transições kickoff e fim-de-janela sem reload/JSON); **igual** → só `updateCountdown()` (dígitos, sem flicker). `renderHero` é idempotente e re-chama `startHeroClock` (guard `if (heroTimer) return`), então langchange/timemodechange não duplicam o timer. `setupCountdown`/`updateCountdown` substituíram `startCountdown` (refs dos dígitos em `countdownEls`/`countdownTarget` módulo-level).
- **Placar no estado live:** só renderiza se `result.homeScore`/`awayScore` **não forem null** (`hasScore`); senão cai pro "vs" (jogo em progresso pelo relógio, JSON ainda sem placar). Decidido com o usuário (opção C) — **sem** tempo decorrido tipo "45'" (seria impreciso num site estático).
- **Badge "Bola rolando!" preservado:** o texto que o usuário via NÃO era `hero.live` ("Ao vivo") — era `hero.kickoff` ("Bola rolando!"). A chave foi **renomeada `hero.kickoff` → `hero.inProgress`** (EN passou de "Kickoff!" → "In progress"; PT continua "Bola rolando!") e o badge do hero usa ela. `hero.live` ficou intacta (ainda usada por `schedule.js:209` e `modal.js:58`). CSS órfão `.hero-kickoff` removido do `style.css`.
- **Escopo: só o hero.** Os badges live de Matches/Modal/Bracket continuam guiados 100% pelo `status` do JSON → pode haver pequena inconsistência transitória (hero diz "em progresso" pelo relógio enquanto o card diz "agendado"). Aceito pelo usuário; "tornar badges time-aware" fica como tarefa futura se incomodar.
- **Fim do torneio:** quando a Final vira `over`, `findFeaturedMatch` retorna `null` → hero vazio (comportamento mantido). TODO registrado: estado pós-Copa da home (campeão/epílogo) quando a Final encerrar.
- **Verificado (preview, relógio fakeado via `Date.now` override + dispatch `langchange`):** 01:00Z→m12 upcoming (countdown 01:00:00); 02:30Z→m12 "Bola rolando!"+vs sem placar/sem cronômetro; 04:30Z→avança pro m13 (m12 ainda `scheduled` no JSON!); 18:30Z→avança pro m14; EN mostra "In progress"; console limpo. Screenshots de upcoming e in-progress conferidos.
### Filtro de ocorrência na aba Matches (2026-06-15)
- **Novo botão de 3 estados** na `filter-row` do schedule que cicla `Todos → Já ocorreram → A ocorrer` (`state.occurred` = `''`/`'occurred'`/`'upcoming'`, via `OCC_CYCLE`). Em memória, **não persistido** — segue a convenção dos outros filtros. `.active` (dourado) quando ≠ 'Todos', igual ao `★ Minhas partidas`. Resetado por **Clear** e `setStadiumFilter`.
- **Reusa a regra híbrida do hero:** `matchState(match, result, now)` foi **exportada de `app.js`** (antes privada) e importada por `schedule.js` — fonte única, sem duplicar a lógica. "Já ocorreram" = estado `over` (status `finished` **OU** relógio passou `kickoff + janela`, 2h grupo / 3h mata-mata); "A ocorrer" = `live` + `upcoming` (jogo ao vivo ainda não "ocorreu").
- **Inconsistência relógio×JSON resolvida no card:** quando `status==='scheduled'` mas `matchState==='over'`, o card mostra o chip **"Pendente de resultado"** (`status.pending`; EN "Awaiting result"; cor `--accent-blue`, `.match-status.pending`) em vez do "vs" mudo. Os badges live de Matches/Modal/Bracket continuam 100% guiados pelo `status` do JSON (escopo do hero inteligente mantido — só o caso `over` ganhou tratamento aqui).
- **Lista fica fresca via timer leve de 60s** (`startOccurrenceClock`/`countOverMatches`, `OCC_TICK_MS`): assinatura = **nº de jogos `over` agora** (monotônico, pois `over` nunca volta). Só `renderList()` quando a contagem muda → nada de repaint dos 104 cards por segundo. `renderList` sincroniza a baseline `overSignature` ao final (qualquer re-render por evento mantém o timer em dia).
- **`renderList`/`matchesFilters`/`matchCardHTML` agora recebem `now`** (um único `Date.now()` por render, consistente entre filtro e chips). Chaves i18n novas: `schedule.occAria/occAll/occPlayed/occUpcoming` + `status.pending` (EN/PT).
- **Verificado (preview, relógio fakeado via `Date.now` override + dispatch `timemodechange`):** Todos 104 / Já ocorreram 12 / A ocorrer 92; com `now=2026-06-16T03:00Z` → 4 chips "Pendente de resultado", Já ocorreram=16 / A ocorrer=88; Clear reseta pra Todos/104; rótulos EN ("All matches/Played/Upcoming") sobrevivem ao langchange; console limpo. Screenshot do estado "Já ocorreram" conferido.
### Hero com jogos simultâneos (2026-06-15)
- **A home agora exibe 2+ partidas quando caem no mesmo kickoff** (última rodada de grupos: ids 4972, 12 pares — mesmo grupo, estádios diferentes; confirmado na `matches.json`: 12 slots, **sempre exatamente 2**). Antes o hero só mostrava 1 jogo (`findFeaturedMatch`).
- **`findFeaturedMatch``findFeaturedMatches(now)`** (`app.js`): pega o jogo não-`over` de kickoff mais cedo e retorna **todos** que compartilham aquele kickoff exato (`getTime()`). Genérico (1/2/N), mas o dado real é sempre 2. `heroSignature(featured, now)` agora cobre o **conjunto** (`id:estado` join por `|`) → o `heroTick` de 1s já existente detecta entrada/saída/troca de estado e re-renderiza. **Mesmo timer/countdown**, como pedido.
- **`renderHero` ramifica:** 1 jogo = **DOM idêntico ao de antes** (sem wrapper, meta completa `hora · estádio, cidade`, regressão zero — verificado); 2+ = 1 rótulo plural compartilhado (`hero.nextMatches`, EN "Next matches"/PT "Próximas partidas") + 1 fase (todos do mesmo grupo) + **1 linha de hora compartilhada** (`.hero-time`) + N confrontos empilhados (`.hero-matchups` `.hero-match`, separados por `.hero-divider`), **cada um com seu próprio estádio** e seu próprio placar/`vs` + **1 countdown compartilhado**. Badge "Bola rolando!" e supressão do countdown são por-slot (todos compartilham kickoff+janela → estado de relógio sincronizado).
- **`heroMatchupHTML(match, now, multi)`** extraído (uma linha de confronto + meta; `multi` tira a hora da meta, deixa só o estádio).
- **Gotchas conhecidos:** (a) a hora compartilhada usa o estádio de `featured[0]` no modo "hora do estádio" — os pares reais são do mesmo fuso, então fica correto; (b) se um dos dois for marcado `finished` no JSON **antes** da janela do slot fechar, ele vira `over` e **sai** do conjunto (o hero mostraria só o outro até a janela acabar) — não ocorre na prática porque o update diário é feito depois que ambos já encerraram pelo relógio.
- **Verificado (preview, relógio fakeado):** real-now → 1 jogo idêntico (ESP-CPV, "Bola rolando!"); 24/06 18:30Z → 2 confrontos (SUI×CAN BC Place / BIH×QAT Lumen Field), "Próximas partidas Grupo B", hora compartilhada, 1 divisória, 1 countdown, 4 bandeiras; 19:30Z → ambos "Bola rolando!"/vs sem countdown; restauro do relógio volta ao hero de 1 jogo sem resíduo; EN "Next matches Group B"; mobile 375px empilha certo; console limpo. Screenshots desktop+mobile conferidos.
### Header responsivo — 2 faixas + abas roláveis (2026-06-15)
- **Problema:** o header (logo + 6 abas + controles hora/idioma) tentava virar linha única já a partir de **768px**, mas só cabe com ~950px de conteúdo → entre 768~1100px os controles vazavam pra uma 2ª linha quebrada; no estreito a faixa de abas (scroll-x, scrollbar escondida) cortava a aba ativa sem pista (parecia bug).
- **Solução (CSS):** o flip pra linha única (`.tabs { flex:0 1 auto; order:0; margin-inline:auto }`) subiu de `@media (min-width:768px)`**`@media (min-width:1100px)`**. Abaixo disso vale o base mobile-first = **2 faixas estáveis**: faixa 1 = logo + controles (`margin-left:auto`), faixa 2 = abas (`flex:1 1 100%; order:3`, scroll-x). `.logo` e `.header-controls` ganharam `flex-shrink:0`. Breakpoint medido no preview: container `min(1200px,100%2rem)`; single-row precisa ~950px (logo 166 + abas 561 PT + controles 191 + gaps) → 1100 dá ~118px de folga (1099=2 faixas/98px, 1100=1 linha/59px, confirmado).
- **Fade nas bordas das abas:** `.tabs.fade-left`/`.fade-right` aplicam `mask-image` (gradiente 28px), ligadas/desligadas por JS (`updateTabFades` em `app.js`) só do lado com aba pra rolar (scrollWidth/scrollLeft/clientWidth). Some sem overflow.
- **Aba ativa sempre visível:** `scrollActiveTabIntoView(smooth)` centraliza a aba ativa via `scrollLeft` (cálculo por getBoundingClientRect — **sem** `scrollIntoView`, pra não rolar a página). Chamada em `activateTab` (smooth) e em load/resize/langchange (instantâneo); listeners (scroll passivo, resize rAF, langchange) montados em `initTabs`.
- **Botão de hora vira ícone no estreito:** `syncTimeToggle` agora monta `<span.time-icon>🕐</span><span.time-label>…</span>`; `@media (max-width:420px) .time-label{display:none}` → só o relógio, logo+controles cabem numa faixa até ~360px. A11y intacta (nome acessível vem de `data-i18n-aria="time.toggleAria"`, não do texto). `.control-btn` virou `inline-flex`. **Nota:** isso supera a linha "7681439 single-row header" da entrada "Responsive/a11y decisions (2026-06-12)".
- **Verificado (preview, eval-geometry acima da largura nativa + screenshot mobile):** 375px→2 faixas, hora só ícone, fade-right, logo+controles juntos; 900px (zona antiga quebrada)→2 faixas estáveis, controles não vazam, "Hora local" completo; 1099→2 faixas; 1100→1 linha centrada; clicar Estatísticas rola a faixa até o fim + troca pra fade-left com a aba 100% visível; console limpo.
### How to update real-world data (scores, schedule)
Follow `how-refresh-data.md` (project root). In short:
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
2. Once group stage ends: fill `data/bracket-config.json``thirdPlaceAssignment` (slot → group letter). Nothing else changes.
### Real-data migration (2026-06-12)
- `how-update.md` (project root) is the full runbook for replacing mock `data/*.json` with real World Cup 2026 data: file-by-file schemas, order of operations (stadiums → teams → groups → bracket-config.round32 → matches → results → thirdPlaceAssignment), and a cross-file integrity checklist (group membership, id ranges, bracketRef uniqueness, stadium name/city matches).
- Flags one open decision: `stadiums.json` has 30 entries (original bid shortlist) vs. the 16 venues actually used by the real tournament — confirm with user whether to trim before/while editing `matches.json`.
### Real-data migration DONE (2026-06-12)
- **All 6 `data/*.json` files now hold real WC2026 data.** Sources: Wikipedia per-group + knockout-stage articles (primary; local kickoff times with explicit UTC offsets), cross-checked vs ESPN/NBC/FOX/olympics.com/lumenfield.com. Full smoke test + desktop/mobile layout pass, console clean.
- **Stadiums trimmed 30 → 16** (user decision). Cities use FIFA host-city names ("New York/New Jersey", "San Francisco Bay Area", "Boston"), not suburb names (East Rutherford, Santa Clara, Foxborough) — `matches.json` and `stadiums.json` must keep matching exactly.
- **Match ids:** group matches 172 are *chronological by UTC kickoff* (≠ 6-per-group blocks); knockout ids 73104 are FIFA's official match numbers.
- **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 (ordering chosen so the app's sequential pairing reproduces the official R16/QF/SF progression).
- **Third-place slots → allowed groups** (for filling `thirdPlaceAssignment` after the group stage, per official draw): slot 1 (M74) A/B/C/D/F · slot 2 (M77) C/D/F/G/H · slot 3 (M81) B/E/F/I/J · slot 4 (M82) A/E/H/I/J · slot 5 (M79) C/E/F/H/I · slot 6 (M80) E/H/I/J/K · slot 7 (M85) E/F/G/I/J · slot 8 (M87) D/E/I/J/L. Each group letter may appear in only one slot.
- **Results as of 2026-06-12:** ids 13 finished (MEX 20 RSA, KOR 21 CZE, CAN 11 BIH); USAPAR (id 4) kicked off 01:00 UTC Jun 13 — first thing to update next session.
### Daily refresh (2026-06-13)
- **Results updated through match id 6** (matchday 1 complete):
- id 4: USA 41 PAR (Group D) — confirmed FIFA + Yahoo/ESPN
- id 5: QAT 11 SUI (Group B) — confirmed FOX Sports + ESPN (Khoukhi 94th-min header)
- id 6: BRA 11 MAR (Group C) — confirmed FOX Sports + NBC Sports (Saibari for MAR, Vinicius Jr. for BRA)
- Verified standings in Groups view: Group B shows Qatar/Switzerland each 1 pt; Group C shows Brazil/Morocco each 1 pt; Group D confirms USA 3 pts (W 4-1), Paraguay 0 pts.
- **Single-source caveat:** R16 match 94 (Jul 6, Lumen Field) time 17:00 PDT per Wikipedia; one ESPN summary implied 14:00 PDT. Re-verify when R16 nears.
- Next: matches 78 scheduled Jun 14 (HAISCO, AUSTUR, both Group stage). Continue daily routine per `how-refresh-data.md`.
### Daily refresh (2026-06-14)
- **Results updated through match id 7** (HAISCO):
- id 7: HAI 01 SCO (Group C) — confirmed Outlook India + VAVEL USA (McGinn 28')
- Verified standings in Groups view: Group C now shows Scotland 1W 3pts, Brazil/Morocco 1D 1pt each, Haiti 1L 0pts.
- Next: matches 8+ scheduled Jun 14 (AUSTUR onwards). Continue daily routine.
### Daily refresh (2026-06-14 — stats backfill)
- **Stats added to matches 17:**
- id 1 (MEX 20 RSA): possession 60/40, shots 16/2, cards 1/4 — sources: Yahoo Sports box score, ESPN
- id 2 (KOR 21 CZE): possession 62/38, shots 16/4, cards 1/0 — sources: ESPN, Opta Analyst
- id 3 (CAN 11 BIH): possession 61/39, shots 13/8, cards 1/3 — sources: ESPN, VAVEL USA
- id 4 (USA 41 PAR): possession 65/35, shots 16/9, cards 1/5 — sources: ESPN, Opta Analyst
- id 5 (QAT 11 SUI): possession 44/56, shots 8/11, cards 2/1 — sources: ESPN, Opta Analyst (possession adjusted to 100%)
- id 6 (BRA 11 MAR): already had stats from prior session (possession 51/49, shots 12/14, cards 2/0)
- id 7 (HAI 01 SCO): possession 48/52, shots 1/2, cards 1/0 — sources: ESPN, Sofascore
- Verified in preview: all seven matches now display stats in modal (confirmed via preview_eval). Possession, shots, and card counts render correctly in both EN and PT.
- No new match results to add (matches 8+ still scheduled).
- Next: continue daily routine when matches 8+ complete.
### Daily refresh (2026-06-14 — match 8)
- **Results updated through match id 8** (AUSTUR):
- id 8: AUS 20 TUR (Group D) — confirmed VAVEL USA + FOX Sports (Irankunda 27', Metcalfe 75')
- Stats added: possession 28/72, shots 9/30, cards 0/1 — sources: ESPN, Outlook India, VAVEL USA
- Match played at BC Place, Vancouver. Australia defended deep with 28% possession but converted two chances; Akgun (TUR) yellow card 86'
- Verified in preview: match 8 displays in Groups view with correct stats modal
- Next: continue with remaining matches (9+) on Jun 14 schedule
### Daily refresh (2026-06-14 — match 9)
- **Results updated through match id 9** (GERCUW):
- id 9: GER 71 CUW (Group E) — confirmed FIFA match centre + ESPN + FOX Sports (Nmecha 6', Schlotterbeck 38', Havertz 45'+5'p & 88', Musiala 47', N.Brown 68', Undav 78', Comenencia 21')
- Stats added: possession 65/35, shots 27/8, cards 0/0 — sources: ESPN match centre (clean match, no disciplinary action)
- Germany dominant performance at NRG Stadium, Houston. Possession and shot dominance translated to decisive victory.
- Verified in preview: match 9 displays with correct score and stats modal; Groups view updated
- DATA_VERSION bumped to 2026-06-14-rev2
- Next: continue with remaining matches (10+) on Jun 14 schedule
### Daily refresh (2026-06-14 — matches 1011)
- **Results updated through match id 11:**
- id 10: NED 22 JPN (Group F) — confirmed ESPN + NBC/Yahoo + FIFA (Van Dijk & Summerville for NED; Nakamura & Kamada 89' for JPN). Stats: possession 60/40, shots 10/10, cards 3/0 (NED: Van de Ven 90+1', Summerville 61', Depay 83'; JPN none) — ESPN + Newsbytes/Sofascore + FOX boxscore.
- id 11: CIV 10 ECU (Group E) — confirmed ESPN + Outlook India/Yahoo (Amad Diallo 90', assist Singo). Stats: possession 48/52, shots 13/11, cards 3/1 (CIV: Kessie 38', Fofana 76', Doue 88'; ECU: Porozo 62') — ESPN + Yahoo + FOX boxscore.
- Verified in preview: both modals show correct possession/shots/cards (EN/PT), console clean. Group E now Germany 3pts (7-1) + Ivory Coast 3pts (1-0); Group F Japan & Netherlands 1pt each (2-2).
- DATA_VERSION bumped to 2026-06-14-rev3.
- Match 12 (SWETUN, 2026-06-15 02:00 UTC) had not kicked off yet — left scheduled. `thirdPlaceAssignment` untouched (group stage not over).
- Next: continue with matches 12+ on Jun 15 schedule.
### Daily refresh (2026-06-15 — match 12)
- **Results updated through match id 12** (SWETUN):
- id 12: SWE 51 TUN (Group F) — confirmed FIFA match centre + ESPN + Bolavip (Ayari 7' & 90'+6', Isak 30', Gyökeres 59', Svanberg 84' for SWE; Omar Rekik 43' for TUN). Played at Estadio BBVA, Monterrey.
- Stats added: possession 49/51, shots 17/5, cards 0/1 — sources: ESPN matchstats (possession + total shots 17/5; ESPN "shots on goal" 7/2 = on target, NOT used) + Sofascore/VAVEL (only booking = Rani Khedira yellow 54' for TUN → SWE 0, TUN 1).
- Verified in preview: m12 modal shows 49%/17/0 vs 51%/5/1 (PT), console clean. Group F standings now Sweden 3pts (5-1, +4) leading; Japan & Netherlands 1pt each (2-2); Tunisia 0pts (1-5, -4).
- DATA_VERSION bumped to **2026-06-15-rev1**.
- This run rode together with the smart-hero feature commit (see "Hero cronômetro inteligente" entry) — both pending push/deploy at user's call.
- Next: match 13 (ESPCPV, Group H, 2026-06-15 16:00 UTC) and onwards still scheduled. `thirdPlaceAssignment` untouched (group stage not over).
### Daily refresh (2026-06-15 — match 13)
- **Results updated through match id 13** (ESPCPV):
- id 13: ESP 00 CPV (Group H) — confirmed ESPN ("Spain 0-0 Cape Verde Final Score") + FOX Sports + FIFA match centre. Empate histórico: Cabo Verde (estreante na Copa) segura a Espanha; GK Vozinha (40 anos) com 8 defesas, trave em chute de F. Torres.
- Stats added: possession 74/26, shots 27/6, cards 1/1 — sources: posse 74% + chutes 27-6 (FIFA match centre + resumo final, 804 vs 304 passes); cartões 1/1 (FOX live blog: Pedri 90+3' ESP, Sidny Lopes Cabral 75' CPV; sem vermelhos).
- Verified in preview: Group H agora Spain & Cape Verde 1pt cada (0-0), Saudi Arabia/Uruguay 0; modal do jogo 13 mostra 74%/27/1 vs 26%/6/1 (PT) com a nota "stats em breve" sumida; hero da home avançou pro jogo 14 (BELEGY, Group G) com countdown; console limpo.
- **DATA_VERSION bumped to 2026-06-15-rev2** (segunda edição do mesmo dia, após o rev1 do match 12 + smart-hero).
- Next: match 14 (BELEGY, Group G, 2026-06-15 19:00 UTC) e seguintes ainda `scheduled`. `thirdPlaceAssignment` intacto (13/72 jogos de grupo concluídos).
### Daily refresh runbook (2026-06-12)
- **`how-refresh-data.md` (project root) is the runbook for all updates during the tournament** — read it before touching any `data/*.json` from now on. It defines: daily `results.json` routine (scores/status, two-source rule, penalties only on ids 73104), the one-time `thirdPlaceAssignment` fill (~Jun 2728, slot → allowed-groups table), and the frozen files (stadiums/teams/groups/round32/assets/code — never edit).
- `how-update.md` stays as the schema reference for the (completed) mock → real migration; `how-refresh-data.md` supersedes it for day-to-day work.
### CI/CD — deploy automático para Hostinger via FTP (2026-06-14)
- **GitHub Actions** em `.github/workflows/deploy.yml`: a cada `push` em `master` (ou `workflow_dispatch` manual) envia o site pra Hostinger usando `SamKirkland/FTP-Deploy-Action@v4.3.5`.
- **Remote GitHub:** `origin` = `https://github.com/LucasKalil-Programador/world-2026-hub.git` (branch `master`). Push via credential manager do Windows (gh CLI NÃO está instalado nesta máquina).
- **Secrets necessários no repo** (Settings → Secrets and variables → Actions): `FTP_SERVER`, `FTP_USERNAME`, `FTP_PASSWORD` — vêm do hPanel da Hostinger (Files → FTP Accounts). Sem eles o workflow falha.
- **Config do workflow:** `protocol: ftps`, `port: 21`, `local-dir: ./`, `server-dir: worldcup2026/`. **Gotcha confirmado 2026-06-14:** a conta FTP da Hostinger faz login JÁ DENTRO de `public_html`, então o `server-dir` é relativo a ele — NÃO prefixar `public_html/` (causa `public_html/public_html/worldcup2026`). O destino final no disco é `public_html/worldcup2026/`. `exclude` remove do deploy: `.git*`, `.github/`, `.agents/`, `README.md`, `how-*.md`, specs `*-en.md` — só `index.html` + `assets/` + `data/` chegam ao site.
- **Sync incremental:** a action mantém `.ftp-deploy-sync-state.json` no servidor; só reenvia arquivos alterados. Não comitar esse arquivo (vive só no servidor).
- **Gotcha:** se a Hostinger não aceitar FTPS explícito, trocar `protocol` para `ftp`. Se o site ficar em subpasta, lembrar do gotcha #7 (paths relativos) — já está OK no projeto.
### Stadium SVG cleanup (2026-06-14)
- **All 16 `assets/images/stadiums/*.svg` stripped of the card chrome**: removed the `.card` background rect, top/bottom accent bars, `name`/`city`/`cap` `<text>` elements, divider `<line>`s, and the now-unused `.card`/`.name`/`.city`/`.cap`/`.div` style rules — `stadiums.js` already renders name/city/capacity as HTML, so the SVG no longer duplicates them.
- **`viewBox` cropped to just the `<g>` illustration** (~10px padding) per file, and the fixed `width="300" height="400"` attrs removed so the SVG's intrinsic aspect ratio matches its `viewBox` — needed for `.stadium-img { aspect-ratio: ...; object-fit: cover }` to crop correctly instead of showing a letterboxed 3:4 image.
- **White rectangular shapes still visible at the top of some cards (BBVA, Lumen, BMO) are intentional** — they're each stadium's press-box/scoreboard tower (`class="void"`, connected to the bowl by thin lines), not leftover text artifacts. Don't remove them.
- If adding a new stadium SVG, follow this trimmed structure: `<svg viewBox="...">` (no width/height) → `<defs><style>` with only `struct/thin/hair/concrete/stands/canopy/void/pitch/pline/acc/accs/green/ribs/louver` classes + `frit` pattern → single `<g>` with the illustration, cropped tightly. Aim for a viewBox aspect ratio near 4:3 (~1.2-1.3), to match `.stadium-img`'s `aspect-ratio: 4/3`.
### Stadium card image aspect ratio (2026-06-14)
- **`.stadium-img` (in `assets/css/style.css`) uses `aspect-ratio: 4/3`**, not `16/9`. The 16 cropped stadium SVG viewBoxes have natural ratios ~1.07-1.32 (avg ~1.25), much closer to 4:3 (1.333) than 16:9 (1.778) — `16/9` forced `object-fit: cover` to crop ~28% of the image height, slicing through rounded-rect/curved illustration paths (visible as a "diagonal" cut on NRG). `4/3` shows each illustration nearly whole across all 16 cards, including the BBVA/Lumen/BMO press-box towers and the Mercedes-Benz pinwheel.
### Data cache-busting via DATA_VERSION (2026-06-14)
- **`assets/js/app.js` `loadData()` appends `?v=${DATA_VERSION}`** to every `data/*.json` fetch (`DATA_VERSION` constant near the top of the file, currently `'2026-06-13-rev1'`). Fixes production browsers/Hostinger caching stale `results.json` after a daily refresh — `cache: 'reload'` only helps the developer's own browser, not real visitors.
- **Must be bumped on every data refresh** — added as step 4 of the daily routine in `how-refresh-data.md`. Format `YYYY-MM-DD-revN`; increment `revN` for same-day re-edits.
### Stats screen — planning (2026-06-14)
- **Plano completo em `.agents/stats-screen-plan.md`** (pós-Copa "tela de estatísticas finais"). Gerado via workflow de 5 sub-agentes (Times/Individuais/Partidas/Curiosidades/UX). **Nada implementado ainda** — só planejamento, aprovado pelo usuário.
- **Escopo aprovado: as 4 camadas** — ✅ dados existentes · 🟡🧩 acréscimos baratos (attendance, cartões y/r, ranking/wcDebut/confederation em teams.json, coords em stadiums.json, backfill do `stats`) · 🔴 dados de jogadores (`players.json` + log de eventos + `awards.json`) · 📝 editorial (`curiosities.json`).
- **REQUISITO DE 1ª ORDEM — degradação graciosa:** quando um dado não existir, a UI **não pode quebrar nem deixar visível ao usuário final que falta algo**. Regra: dado/seção só renderiza com cobertura completa; senão é **removido do DOM** (não placeholder/"—"/"em breve"); chips de sub-nav de seções vazias também somem; `loadData()` deve tolerar fetch de arquivo novo ausente (default vazio, não exceção).
- **Decisões técnicas planejadas:** novo 6º tab `stats``#panel-stats` + `assets/js/stats.js` + `assets/css/stats.css` (segue padrão por-view + import circular com app.js); sub-nav = `<nav>` de âncoras com scrollspy (NÃO um 2º tablist); **sem chart-lib** (SVG/CSS inline, respeita budget <300KB); render preguiçoso por seção; modelo de stats memoizado, re-render de labels no `langchange`. Roadmap AJ com portões de aprovação (AF entregam a tela com dados de hoje; G/H/I acendem camadas 2/3/4).
### Stats tab — PARCIAL durante a copa (2026-06-14, Etapa A+B)
- **Nova aba `stats`** implementada de forma incremental (plano em `C:\Users\Lucas\.claude\plans\contexto-o-planejamento-foamy-brook.md`). É a **fundação evolutiva** do plano pós-copa (`.agents/stats-screen-plan.md`) mesmo tab/módulo; seções pós-copa "acendem" depois.
- **Arquivos novos:** `assets/js/stats.js` + `assets/css/stats.css`. Integração: `stats.css` linkado no `<head>`; botão+painel `panel-stats`/`#stats-root` no `index.html`; `'stats'` adicionado a `TABS` em `app.js`; `initStats()` chamado no `try` de `init()`; chaves `nav.stats` + `stats.*` (EN/PT) em `i18n.js`.
- **Filosofia (decidida via /grill-me):** agregados correntes "até agora", **só `status==='finished'`** (consistente com `computeStandings`); "X de 104" é moldura, não lacuna. `stats` opcional (posse/chutes/cartões) entra com **gating por-jogo** (`aggregateTeams` ignora jogo sem o campo). Sem polling recomputa no load + re-render de labels no `langchange` (modelo memoizado em `let model`).
- **Implementado nesta etapa (A+B):** `aggregateTeams()` (agregação torneio-wide, grupo+mata-mata, própria `computeStandings` é por-grupo); hero "pulso" com tiles count-up (IntersectionObserver dispara a animação quando o painel abre, reduced-motion-safe); Overview (jogos/decididas/empates) + chart "gols por fase" ( fases com 1 jogo sem barras zeradas de R32+); link "ver partidas" `navigateTo('matches')`. Verificado: 27 gols/3.00 média/margem 6/3 clean sheets, ENPT, mobile 2×2, console limpo.
- **Seção "Estatísticas por time" (2026-06-14, +/grill-me):** tabela paginada das **48 seleções, 6 páginas fixas de 8** (`PAGE_SIZE`/`COLUMNS` no topo de `stats.js`). 10 colunas ordenáveis (J/V/E/D/GP/GC/SG/Pts/G·J/CS; rótulos curtos reusam `standings.*`, os 2 novos têm `title`). Clique no header faz **toggle desc↔asc** (seta + `aria-sort`), default **GP desc** ao abrir; desempate fixo SGGPnome; trocar coluna ou re-ordenar volta à pág. 1. Coluna # = **rank global** 148. Times sem jogo = zeros reais (não lacuna), caem ao fim. Faixa **top-3 leaders** (melhor ataque=GP / defesa=GC entre quem jogou / mais clean sheets) acima `computeLeaders` considera `played>0`. Estado `sortKey`/`sortDir`/`teamPage` é módulo-level e **sobrevive ao langchange** (como o zoom do bracket); ordenação/paginação re-renderizam `#stats-teams-table` (não re-disparam count-ups). Mobile: `.stats-table-wrap` scroll-x com `#`+`Seleção` `position:sticky` (left 0 / 2.5rem). Verificado: sort/toggle/troca-de-coluna/paginação/ENPT/mobile-sticky, console limpo.
- **Falta (etapas DG, aguardando aprovação):** líderes posse/chutes/disciplina; recordes auto (maior goleadamodal); comparador time-vs-time; polimento. **Sem** arquivo de resultados (linka p/ Matches). `penalties` ainda não existe em results.json card de pênaltis renderiza quando 1 disputa ocorrer.
### Tooltips de header + legenda (2026-06-14, +/grill-me)
- **Tooltips nas siglas das tabelas** (Stats "Estatísticas por time" **e** as 12 tabelas de Grupos). Decidido via /grill-me: tooltip custom glass (não `title` nativo), **nome + definição onde ajuda**, e **legenda compacta só no mobile**.
- **`initTooltips()` em `app.js`** (chamado no `init()`): um único balão `.app-tooltip` `position:fixed`, via **delegação de eventos** em `document` (`mouseover`/`focusin` mostram, `mouseout`/`focusout`/`scroll` escondem). Sobrevive a re-renders das tabelas e **nunca é cortado** por overflow/stacking (motivo de não usar `::after` dentro do `.stats-table-wrap` que tem `overflow-x:auto`). Centraliza sobre o elemento, faz clamp na viewport e **flip pra baixo** se não couber acima.
- **Como dar tooltip a um header:** adicionar classe `has-tip` + `data-tip="<texto>"` + `aria-label="<sigla> — <texto>"` (o aria-label cobre leitor de tela, que o balão é visual). Textos em `i18n.js` no namespace **`tip.*`** (EN/PT), reusados pelas duas tabelas (`tip.played/won/drawn/lost/gf/ga/gd/pts/gpg/cs`).
- **Legenda mobile:** `<p class="stats-legend">` com pares `<b>sigla</b> = texto` `display:none` no desktop, `flex` em `≤600px` (estilo em `stats.css`). Uma por tabela: `legendHTML()` em `stats.js` (usa `COLUMNS`) e em `groups.js` (lista fixa das 8 colunas). Cobre o touch, onde o hover não dispara.
- CSS do tooltip/legenda vive em `stats.css` (carregado global, então `.has-tip`/`.app-tooltip`/`.stats-legend` valem também na aba Grupos).
### 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 the string.
### How to add a new localStorage preference
1. Extend the `wc2026_prefs` object shape (document the new field here).
2. Read/write only via `storage.js` `get`/`set`.
### How to add a step summary after finishing a build step
1. Mark the step `[x] ~~...~~` in `.agents/TODO.md`.
2. Append any new decisions/gotchas here (never rewrite existing entries).
3. Rewrite `project-map.md` if structure/functions changed.
4. Stop and wait for user approval before the next step.
---
## Success metrics
- Lighthouse > 90; first render < 2s; total JS < 300KB.
- Spec §18 acceptance criteria all checked (tracked in README checklist, step 11).
---
## Communication
- User communicates in English/Portuguese mix; docs in English per conventions.
- **Ask before each build step** never chain into the next step without explicit go-ahead.