# 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 A–F, `scheduled` for G–L (match 61, Group G, is `live`). So R32 slots fed by A–F resolve to real teams; G–L 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 `` + `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.4–2. - **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). - **Drag–click 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 `` 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), 768–1439 (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://.github.io//`, 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 1–1 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 49–72, 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 `🕐…`; `@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 "768–1439 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. ### Live data refresh — poll de `results.json` sem F5 (2026-06-16, Opção A⁺) - **Problema:** aba aberta carregava `data/*.json` 1x no load e nunca mais; um novo `results.json` publicado (placar/stats do refresh diário) só aparecia após F5. Implementada a **Opção A⁺** analisada em `.agents/issues.md` (poll fixo + 3 reforços baratos), aprovada pelo usuário. - **Reframe que guiou o design:** o dado **não é live** — é push manual pós-jogo. Então o que importa é "dev publicou → aba aberta vê em ≤1 intervalo", limitado pelo intervalo **independente do estado da partida**. Por isso poll **fixo** (não dinâmico/30s-no-live: não há dado novo no servidor durante o jogo). O hero inteligente já cobre a sensação de "vivo" pelo relógio; o poll só traz o **dado novo**. - **Motor (`app.js`, seção "live data refresh" logo após `getData()`):** `startResultsPolling()` (chamado no fim do `try` de `init()`, **depois** das views registrarem seus listeners) arma 1 `setInterval` de `POLL_INTERVAL_MS = 90s` (guard anti-duplicata `if (pollTimer) return`, igual `startHeroClock`). `pollResults()` busca `data/results.json?t=${Date.now()}` com `cache:'no-store'` (**não** usa `DATA_VERSION` — constante congelada na aba + Hostinger sem cache headers, gotcha #2). Assinatura = `JSON.stringify(results)` (conteúdo, não count de finished — pega correção de placar, backfill de `stats` e pênaltis); igual → `return` sem churn. Mudou → reescreve `data.results` **e reconstrói `data.resultByMatchId`** (mapa derivado; trocar só `.results` deixaria o mapa velho), `invalidateBracket()` (árvore cacheada), `dispatchEvent(new CustomEvent('datachange'))`. - **Os 3 reforços sobre a Opção A pura:** (1) **Page Visibility** — `setInterval` checa `!document.hidden`; `visibilitychange` faz fetch imediato ao voltar (`onVisibility`); aba em background = poll no-op (browser já throttla). (2) **Parar no fim** — `tournamentOver()` = `resultByMatchId.get(FINAL.id)?.status === 'finished'` (guard no **status do JSON**, não no `over` de relógio, senão pararia 3h após kickoff antes do placar sair) → `stopResultsPolling()` limpa interval + remove o listener de visibility. (3) **Assinatura por conteúdo** (acima). - **Fan-out de re-render (reusa o padrão de eventos existente):** cada view ganhou `document.addEventListener('datachange', ...)`: `app.js`→`renderHome` (hero+dashboard counts), `schedule.js`→`renderList`, `groups.js`→`render` (recomputa standings; `computeStandings` não tem cache, só re-render), `bracket.js`→`render` (árvore já invalidada pelo poll → reconstrói), `stats.js`→`{ model = null; render() }` (modelo memoizado precisa rebuild). `datachange` é o **5º evento custom** (junto de `langchange`/`simchange`/`favchange`/`timemodechange`). `app.js` agora importa `invalidateBracket` do `bracket.js`. - **`bracket-config.json` (thirdPlaceAssignment) — piggyback no evento de mudança:** o poll busca **só** `results.json` a cada tick, mas quando detecta mudança **rebusca também o `bracket-config.json` no mesmo ciclo** (`data.bracketConfig = await cfg.json()`, try/catch → mantém o config em memória se falhar). Racional (apontado pelo usuário 2026-06-16): o preenchimento único dos 8 terceiros (~27/06) só sai **junto** com um update de results (mesmo push), então não precisa pollar o config a cada 90s — pega carona no evento raro. Fecha a brecha em que os slots de 3º lugar exigiriam F5. **Cuidado:** "config muda junto no servidor" **não** bastava sozinho — o poll não buscava o config, então a aba ficaria com o `bracketConfig` velho; é o refetch explícito que resolve. Verificado: ao mudar results, o poll faz fetch de `data/results.json` **e** `data/bracket-config.json` no mesmo ciclo (console limpo). - **Não tratado (aceito, baixo risco — mudanças raras, poucas/dia):** modal aberto não auto-atualiza (relê no próximo open); re-render durante interação (drag do bracket / digitação no filtro) — filtros sobrevivem (state módulo-level), scroll pode pular. - **Verificado (preview, sem tocar no disco — `window.fetch` interceptado pra simular jogo 16 IRN×NZL finished 3–0, `visibilitychange` disparando `pollResults`):** dashboard Encerradas 15→16 / Próximas 89→88; hero trocou IRN×NZL→FRA×SEN (jogo 16 virou `over`); Group G recomputou (Irã `1 1 0 0 3 0 +3 3`); bracket(32)/stats(4 tiles)/matches(104) re-renderizaram; **console limpo**. Restaurado o `fetch` real → poll seguinte **auto-revertou** pra 15/89 (prova a assinatura nos dois sentidos). `DATA_VERSION` **não** bumpado (nenhum dado mudou no disco — só código). ### PWA — installable app (Tier 1, 2026-06-16) - **O site virou um PWA instalável** (issue "Adicionar suporte a instalação como aplicativo"). Escopo entregue = **Tier 1** (manifest + ícones + meta tags) — atende a TODOS os critérios de aceitação (instalável, nome/ícone corretos, abre standalone pelo atalho do SO). **Service worker / offline ficou de fora de propósito** (Tier 2, registrado em `.agents/issues.md`). - **Decisão-chave (por quê só Tier 1):** um SW que cacheasse `data/*.json` **quebraria** o poll de 90s + `DATA_VERSION` (live-refresh de 2026-06-16) — abas abertas parariam de ver placares novos. Tier 1 não toca em nada do pipeline de dados/JS → risco zero pro live-refresh. Se Tier 2 entrar, o SW **precisa excluir `data/*.json`** do cache (network-only/network-first) e versionar junto com `DATA_VERSION` (senão piora a gotcha #5 de módulo velho). - **Arquivos novos:** `manifest.json` (raiz), `favicon.ico` (raiz), `assets/icons/` (icon.svg master + icon-192/512.png `purpose:any` + icon-maskable-192/512.png + apple-touch-icon.png 180 + favicon-16/32.png + favicon.ico). **Nenhum JS mudou.** `index.html` ganhou um bloco PWA no `` (link manifest, ``, favicons, `apple-mobile-web-app-capable/-status-bar-style black-translucent/-title "WC 2026 Hub"`). - **Ícones derivados do logo do header** (o troféu SVG inline) sobre o gradiente escuro `#10243b→#081421` com o dourado `#d4af37`. Fontes em `assets/icons/icon.svg` (any, troféu a ~60%) e `icon-maskable.svg` (troféu a ~46%, dentro da safe-zone do maskable). **Rasterizados com ImageMagick** (`magick -background none icon.svg -resize NxN ...`); `favicon.ico` = 16+32 combinados. **Para trocar o ícone:** editar o(s) SVG e re-rodar os mesmos comandos `magick` (não há build step; é geração de asset 1x). - **Manifest:** `name "World Cup 2026 Hub"` / `short_name "WC 2026 Hub"` (nome estático, EN — manifest não faz i18n runtime); `display:standalone`; `background_color`+`theme_color` = `#081421` (`--bg-primary`, evita flash branco no splash); `start_url:"."` + `scope:"./"` **relativos** (gotcha #7 — site mora em `…/worldcup2026/`; absoluto quebraria). Nomeado `manifest.json` (não `.webmanifest`) p/ MIME seguro na Hostinger. - **Deploy:** os arquivos novos (manifest.json, favicon.ico, assets/icons/) **não** estão no `exclude` do `deploy.yml` → sobem normalmente. HTTPS da Hostinger já satisfaz o requisito de PWA. - **Verificado (preview localhost:8126, contexto seguro):** manifest 200 e parseado (name/short/start `.`/scope `./`/display standalone/theme `#081421`); todos os ícones 200 `image/png`; `favicon.ico` 200; `` + apple tags presentes; **console limpo**; app intacto (hero FRA×SEN, 4 cards do dashboard, 16 encerradas/88 próximas — sem regressão visual). **Não testável pelo preview:** o prompt de instalação real / "Add to Home Screen" + ícone no SO — confirmar no Chrome/Edge devtools (aba Application) ou num celular após o deploy. ### 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 1–72 are *chronological by UTC kickoff* (≠ 6-per-group blocks); knockout ids 73–104 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 1–3 finished (MEX 2–0 RSA, KOR 2–1 CZE, CAN 1–1 BIH); USA–PAR (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 4–1 PAR (Group D) — confirmed FIFA + Yahoo/ESPN - id 5: QAT 1–1 SUI (Group B) — confirmed FOX Sports + ESPN (Khoukhi 94th-min header) - id 6: BRA 1–1 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 7–8 scheduled Jun 14 (HAI–SCO, AUS–TUR, both Group stage). Continue daily routine per `how-refresh-data.md`. ### Daily refresh (2026-06-14) - **Results updated through match id 7** (HAI–SCO): - id 7: HAI 0–1 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 (AUS–TUR onwards). Continue daily routine. ### Daily refresh (2026-06-14 — stats backfill) - **Stats added to matches 1–7:** - id 1 (MEX 2–0 RSA): possession 60/40, shots 16/2, cards 1/4 — sources: Yahoo Sports box score, ESPN - id 2 (KOR 2–1 CZE): possession 62/38, shots 16/4, cards 1/0 — sources: ESPN, Opta Analyst - id 3 (CAN 1–1 BIH): possession 61/39, shots 13/8, cards 1/3 — sources: ESPN, VAVEL USA - id 4 (USA 4–1 PAR): possession 65/35, shots 16/9, cards 1/5 — sources: ESPN, Opta Analyst - id 5 (QAT 1–1 SUI): possession 44/56, shots 8/11, cards 2/1 — sources: ESPN, Opta Analyst (possession adjusted to 100%) - id 6 (BRA 1–1 MAR): already had stats from prior session (possession 51/49, shots 12/14, cards 2/0) - id 7 (HAI 0–1 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** (AUS–TUR): - id 8: AUS 2–0 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** (GER–CUW): - id 9: GER 7–1 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 10–11) - **Results updated through match id 11:** - id 10: NED 2–2 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 1–0 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 (SWE–TUN, 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** (SWE–TUN): - id 12: SWE 5–1 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 (ESP–CPV, 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** (ESP–CPV): - id 13: ESP 0–0 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 (BEL–EGY, 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 (BEL–EGY, Group G, 2026-06-15 19:00 UTC) e seguintes ainda `scheduled`. `thirdPlaceAssignment` intacto (13/72 jogos de grupo concluídos). ### Daily refresh (2026-06-15 — match 15) - **Results updated through match id 15** (KSA–URU): - id 15: KSA 1–1 URU (Group H) — confirmed Outlook India + ESPN + FOX Sports + Opta/TheAnalyst (Abdulelah Al-Amri 41' para a Arábia; Maxi Araújo 80' empata para o Uruguai de Bielsa). Jogado no Hard Rock Stadium, Miami. - Stats added: possession 33/67, shots 7/28, cards 1/0 — fontes: posse 67% Uruguai (TheAnalyst, maior posse uruguaia em Copas desde 1966) → 33/67; finalizações 7/28 (TheAnalyst — Uruguai com 28 totais, 22 só no 2º tempo; ESPN listou 23/21 mas inconsistente com o domínio, descartado); cartões 1/0 (FOX live blog: amarelo Al-Amri 44' KSA, Uruguai sem cartões). - **Nota:** o match 14 (BEL 1–1 EGY, Group G — posse 54/46, chutes 15/14, cartões 4/3) já estava `finished` no results.json no início deste run (editado num rev3 do mesmo dia, sem entrada de log própria); este refresh cobre o 15. - Verified in preview (rev4): modal do jogo 15 mostra 33%/7/1 vs 67%/28/0 (PT) com a nota "stats em breve" sumida; console limpo. Group H agora com Spain/Cape Verde/Saudi Arabia/Uruguay todos com 1pt (dois 0-0… na verdade ESP-CPV 0-0 e KSA-URU 1-1, 4 times empatados em 1pt). - **DATA_VERSION bumped to 2026-06-15-rev4.** - Next: match 16 (IRN–NZL, Group G, 2026-06-16 01:00 UTC) e seguintes ainda `scheduled`. `thirdPlaceAssignment` intacto (15/72 jogos de grupo concluídos). ### Daily refresh (2026-06-16 — match 16) - **Results updated through match id 16** (IRN–NZL): - id 16: IRN 2–2 NZL (Group G) — confirmed ESPN + Al Jazeera + Sofascore (Elijah Just 7' & 54' for New Zealand; Ramin Rezaeian 32' & Mohammad Mohebi 64' for Iran). Played at SoFi Stadium, Los Angeles. - Stats added: possession 48/52, shots 4/8, cards 1/0 — sources: ESPN (possession + shots); Sofascore (Ehsan Hajsafi yellow 89' for Iran, no yellows for NZL documented). - Verified in preview (rev1): match 16 modal displays correct score 2-2 with stats 48%/4/1 vs 52%/8/0 (PT); console clean. Group G now has Belgium, Egypt, Iran, New Zealand all competing with early results. - **DATA_VERSION bumped to 2026-06-16-rev1.** - Next: match 17 (FRA–SEN, Group I, 2026-06-16 19:00 UTC) onwards still `scheduled`. `thirdPlaceAssignment` untouched (16/72 group stage matches completed). ### Commit convention — standardized (2026-06-15) - **Problema:** cada run do `/update-worldcup` deixava o `/git-semantic-commit` inventar um subject diferente (`data: update match 13 result and stats`, `data: update match 12 …`, etc.) — sem padrão. O usuário fez o último commit à mão num formato limpo (`data: update 15/06/2026 18:00 BELxEGY 1x1`) e pediu pra padronizar a partir dele. - **Padrão definido (full em `how-refresh-data.md` → "Commit convention (standardized)"):** cada refresh = **2 commits**. 1. **Data commit** (`results.json` + `DATA_VERSION`, + `bracket-config.json` no dia do third-place): - 1 jogo → `data: update DD/MM/YYYY HH:MM HOMExAWAY HxA` - N jogos → subject `data: update DD/MM/YYYY — N jogos` + 1 linha de corpo por jogo (`HH:MM HOMExAWAY HxA`). - Pênaltis (só mata-mata): sufixo `(pen HxA)`. 2. **Docs commit:** `docs: log daily refresh DD/MM/YYYY` (mexidas em `.agents/` + TODO). - **Regras:** `DD/MM/YYYY`+`HH:MM` são a data/kickoff **UTC** do jogo (igual `matches.json`); códigos = 3 letras maiúsculas; separadores `x` minúsculo. `.agents/` fica fora do deploy FTP → mantê-lo em commit separado deixa o data commit (o que muda o site) com diff limpo. - **Por quê 2 commits e não 1:** decisão do usuário (2026-06-15) — separa o que vai pro ar (data) do log interno (docs). ### 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 73–104), the one-time `thirdPlaceAssignment` fill (~Jun 27–28, 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` `` elements, divider ``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 `` 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: `` (no width/height) → ``; 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` é só 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" (só 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, EN↔PT, 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 SG→GP→nome; trocar coluna ou re-ordenar volta à pág. 1. Coluna # = **rank global** 1–48. 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` só 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 só `#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/EN↔PT/mobile-sticky, console limpo. - **Falta (etapas D–G, aguardando aprovação):** líderes posse/chutes/disciplina; recordes auto (maior goleada→modal); comparador time-vs-time; polimento. **Sem** arquivo de resultados (linka p/ Matches). `penalties` ainda não existe em results.json → card de pênaltis só 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=""` + `aria-label=""` (o aria-label cobre leitor de tela, já 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:** `

` com pares `sigla = 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.