61 KiB
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
- Spec compliance — both spec files define scope;
complement-spec-worldcup2026-en.mdwins on conflict. - Visual quality — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
- Interactive bracket — hover path highlight, zoom, drag, simulation mode; the centerpiece feature.
- Easy maintenance — real data drop-in via JSON;
bracket-config.jsonis the only file edited after group stage. - 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 inwc2026_prefs.lang. Alternative (EN only) rejected by user. storage.jspulled 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 viaIntl.DateTimeFormat(formatMatchTime)..icsexport depends on this. - Knockout matches carry
bracketRefinstead 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
localStoragewc2026_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 3finishedfor groups A–F,scheduledfor G–L (match 61, Group G, islive). So R32 slots fed by A–F resolve to real teams; G–L and allthirdslots show placeholders. - Knockout results:
R32-2(match 74) finished 1-1 + penalties 4-3;R32-4(match 76) finished 2-0. Everything elsescheduledwithnullscores —results.jsonhas an entry for all 104 matches. - Image paths:
flag/imageJSON values are relative toassets/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 toscheduled). - i18n mechanics: static HTML uses
data-i18n/data-i18n-ariaattributes re-applied byapplyI18n(); dynamic renders callt()and listen for thelangchangeevent ondocument. Phases translate viatranslatePhase()(PT: R32 = "16 avos de final"). - Tab routing: hash (
#matches) +wc2026_prefs.lastTab,history.replaceStateto avoid history spam; precedence on load: hash → lastTab → home. - Default language:
navigator.languagestartsWithpt→ PT, else EN; only persisted when the user clicks the toggle. - Preview server:
.claude/launch.jsonatR:\lucas-kalil\Projects\definesworldcup2026(python http.server, port 8126) for the Claude Preview panel.
Schedule + performance decisions (2026-06-11, step 3)
schedule.js⇄app.jscircular import is intentional —app.jscallsinitSchedule(),schedule.jsimportsgetData/formatMatchTime/flagSrcback. Safe in native ESM because all calls happen after both modules evaluate; keep this pattern forgroups.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 toresolveBracketTeams(). - Perf: no
backdrop-filteron repeated cards —.match-cardoverrides.glassblur (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), notbackground-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 fromgroups.js— bracket.js (step 7) must import these instead of recomputing.
Stadiums decisions (2026-06-11, step 5)
stadiums.jsmodule added — spec §4 has no module for the stadiums view; a dedicated view module keeps the per-view pattern (schedule/groups/bracket) instead of growingapp.js.- Cross-link: "View matches" on a stadium card calls
setStadiumFilter(name)(exported byschedule.js, resets other filters) +navigateTo('matches')(exported byapp.js).
Modal decisions (2026-06-11, step 6)
- Native
<dialog>+showModal()— focus trap, Esc-to-close and::backdropcome free; no custom trap code. Backdrop click detected viaevent.target === dialog(content is in a padded inner div). - Focus restore: opener element saved in
openMatchModal()and re-focused onclose. - Card → modal wiring is event delegation on
#schedule-root(click + Enter/Space keydown), so it survives list re-renders. Cards gottabindex="0" role="button" aria-labelalready 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 byslotDisplay()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: 1slots, so pair children sit at 25%/75% and the next round's node at 50% — pure-CSS connectors (::before/::afterstubs + 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-pathon the canvas. Computed from ref arithmetic (floor(i/2)up,2i/2i+1down), no tree lookup. - Zoom = CSS
transform: scale()on the canvas + a#bracket-zoombox sized tonatural × 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 behiddenat render time (offsetWidth 0). - Pan + pinch via Pointer Events with
touch-action: noneon 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 syntheticel.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. simchangecustom event fires after any pick/reset;schedule.jslistens 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-labelset at open time (match name + phase); schedule count isaria-live="polite"; countdown hasrole="timer"+ label. - Entry animations: every unhidden
.panelfades 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 byprefers-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.jshandles every.fav-btn(schedule, groups, modal) and dispatchesfavchange; each view re-renders itself. Stars never trigger the card/modal click (guard viaclosest('.fav-btn')). Bracket shows highlight only (no stars — nodes too small). Favorite involvement = gold left border. getFavoriteMatches(matches, favorites)lives inbracket.js(needsresolveBracketTeams), imported byschedule.jsfor the "My matches" filter.- Time mode: header
#time-toggleflipswc2026_prefs.timeModeand dispatchestimemodechange;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 viahistory.replaceStatewhether applied or not (prevents re-prompt loops). Decliningconfirm()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 ondocument; 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
statsemresults.jsonpor jogo:{ possession: {home,away}, shots: {home,away}, cards: {home,away} }(home/awayseguemhomeTeam/awayTeamdematches.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.jsrenderiza stats reais quandoresult.statsexiste; senão mantém o placeholder com—+ notamodal.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ó editarresults.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 → mostravahero.kickoff("Bola rolando!") congelado até oresults.jsonmudar. Agora o estado avança pelo relógio mesmo sem update do JSON. matchState(match, result, now)(função pura) é a fonte do estado:oversestatus==='finished'OUnow >= kickoff + janela;livesestatus==='live'OUnow >= kickoff; senãoupcoming. 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 = 2hsematch.phase.startsWith('Group'), senãoKO_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-overde kickoff mais cedo, desempate porid(igualschedule.js). Substituiu o "pega olivedo JSON, senão oscheduledmais cedo".- Motor de 1 interval persistente (
heroTimer/startHeroClock/heroTick): a cada tick computaheroSignature="${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-chamastartHeroClock(guardif (heroTimer) return), então langchange/timemodechange não duplicam o timer.setupCountdown/updateCountdownsubstituíramstartCountdown(refs dos dígitos emcountdownEls/countdownTargetmódulo-level). - Placar no estado live: só renderiza se
result.homeScore/awayScorenã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") — erahero.kickoff("Bola rolando!"). A chave foi renomeadahero.kickoff→hero.inProgress(EN passou de "Kickoff!" → "In progress"; PT continua "Bola rolando!") e o badge do hero usa ela.hero.liveficou intacta (ainda usada porschedule.js:209emodal.js:58). CSS órfão.hero-kickoffremovido dostyle.css. - Escopo: só o hero. Os badges live de Matches/Modal/Bracket continuam guiados 100% pelo
statusdo 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,findFeaturedMatchretornanull→ 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.nowoverride + dispatchlangchange): 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 aindascheduledno 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-rowdo schedule que ciclaTodos → Já ocorreram → A ocorrer(state.occurred=''/'occurred'/'upcoming', viaOCC_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 esetStadiumFilter. - Reusa a regra híbrida do hero:
matchState(match, result, now)foi exportada deapp.js(antes privada) e importada porschedule.js— fonte única, sem duplicar a lógica. "Já ocorreram" = estadoover(statusfinishedOU relógio passoukickoff + 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'masmatchState==='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 pelostatusdo JSON (escopo do hero inteligente mantido — só o casooverganhou tratamento aqui). - Lista fica fresca via timer leve de 60s (
startOccurrenceClock/countOverMatches,OCC_TICK_MS): assinatura = nº de jogosoveragora (monotônico, poisovernunca volta). SórenderList()quando a contagem muda → nada de repaint dos 104 cards por segundo.renderListsincroniza a baselineoverSignatureao final (qualquer re-render por evento mantém o timer em dia). renderList/matchesFilters/matchCardHTMLagora recebemnow(um únicoDate.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.nowoverride + dispatchtimemodechange): Todos 104 / Já ocorreram 12 / A ocorrer 92; comnow=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-overde 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:estadojoin por|) → oheroTickde 1s já existente detecta entrada/saída/troca de estado e re-renderiza. Mesmo timer/countdown, como pedido.renderHeroramifica: 1 jogo = DOM idêntico ao de antes (sem wrapper, meta completahora · 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;multitira 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 marcadofinishedno JSON antes da janela do slot fechar, ele viraovere 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)..logoe.header-controlsganharamflex-shrink:0. Breakpoint medido no preview: containermin(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-rightaplicammask-image(gradiente 28px), ligadas/desligadas por JS (updateTabFadesemapp.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 viascrollLeft(cálculo por getBoundingClientRect — semscrollIntoView, pra não rolar a página). Chamada emactivateTab(smooth) e em load/resize/langchange (instantâneo); listeners (scroll passivo, resize rAF, langchange) montados eminitTabs. - Botão de hora vira ícone no estreito:
syncTimeToggleagora 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 dedata-i18n-aria="time.toggleAria", não do texto)..control-btnvirouinline-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/*.json1x no load e nunca mais; um novoresults.jsonpublicado (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ósgetData()):startResultsPolling()(chamado no fim dotrydeinit(), depois das views registrarem seus listeners) arma 1setIntervaldePOLL_INTERVAL_MS = 90s(guard anti-duplicataif (pollTimer) return, igualstartHeroClock).pollResults()buscadata/results.json?t=${Date.now()}comcache:'no-store'(não usaDATA_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 destatse pênaltis); igual →returnsem churn. Mudou → reescrevedata.resultse reconstróidata.resultByMatchId(mapa derivado; trocar só.resultsdeixaria o mapa velho),invalidateBracket()(árvore cacheada),dispatchEvent(new CustomEvent('datachange')). - Os 3 reforços sobre a Opção A pura: (1) Page Visibility —
setIntervalcheca!document.hidden;visibilitychangefaz 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 nooverde 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;computeStandingsnã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 delangchange/simchange/favchange/timemodechange).app.jsagora importainvalidateBracketdobracket.js. bracket-config.json(thirdPlaceAssignment) — piggyback no evento de mudança: o poll busca sóresults.jsona cada tick, mas quando detecta mudança rebusca também obracket-config.jsonno 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 obracketConfigvelho; é o refetch explícito que resolve. Verificado: ao mudar results, o poll faz fetch dedata/results.jsonedata/bracket-config.jsonno 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.fetchinterceptado pra simular jogo 16 IRN×NZL finished 3–0,visibilitychangedisparandopollResults): dashboard Encerradas 15→16 / Próximas 89→88; hero trocou IRN×NZL→FRA×SEN (jogo 16 virouover); Group G recomputou (Irã1 1 0 0 3 0 +3 3); bracket(32)/stats(4 tiles)/matches(104) re-renderizaram; console limpo. Restaurado ofetchreal → poll seguinte auto-revertou pra 15/89 (prova a assinatura nos dois sentidos).DATA_VERSIONnã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/*.jsonquebraria 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 excluirdata/*.jsondo cache (network-only/network-first) e versionar junto comDATA_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.pngpurpose:any+ icon-maskable-192/512.png + apple-touch-icon.png 180 + favicon-16/32.png + favicon.ico). Nenhum JS mudou.index.htmlganhou um bloco PWA no<head>(link manifest,<meta theme-color #081421>, 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→#081421com o dourado#d4af37. Fontes emassets/icons/icon.svg(any, troféu a ~60%) eicon-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 comandosmagick(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). Nomeadomanifest.json(não.webmanifest) p/ MIME seguro na Hostinger. - Deploy: os arquivos novos (manifest.json, favicon.ico, assets/icons/) não estão no
excludedodeploy.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 200image/png;favicon.ico200;<meta theme-color>+ 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:
- Edit
data/results.json(scores/status) ordata/matches.json(schedule, rare). - 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 mockdata/*.jsonwith 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.jsonhas 30 entries (original bid shortlist) vs. the 16 venues actually used by the real tournament — confirm with user whether to trim before/while editingmatches.json.
Real-data migration DONE (2026-06-12)
- All 6
data/*.jsonfiles 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.jsonandstadiums.jsonmust 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
thirdPlaceAssignmentafter 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.
thirdPlaceAssignmentuntouched (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.
thirdPlaceAssignmentuntouched (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.thirdPlaceAssignmentintacto (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
finishedno 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.thirdPlaceAssignmentintacto (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.thirdPlaceAssignmentuntouched (16/72 group stage matches completed).
Commit convention — standardized (2026-06-15)
- Problema: cada run do
/update-worldcupdeixava o/git-semantic-commitinventar 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.- Data commit (
results.json+DATA_VERSION, +bracket-config.jsonno 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).
- 1 jogo →
- Docs commit:
docs: log daily refresh DD/MM/YYYY(mexidas em.agents/+ TODO).
- Data commit (
- Regras:
DD/MM/YYYY+HH:MMsão a data/kickoff UTC do jogo (igualmatches.json); códigos = 3 letras maiúsculas; separadoresxminú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 anydata/*.jsonfrom now on. It defines: dailyresults.jsonroutine (scores/status, two-source rule, penalties only on ids 73–104), the one-timethirdPlaceAssignmentfill (~Jun 27–28, slot → allowed-groups table), and the frozen files (stadiums/teams/groups/round32/assets/code — never edit).how-update.mdstays as the schema reference for the (completed) mock → real migration;how-refresh-data.mdsupersedes 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 cadapushemmaster(ouworkflow_dispatchmanual) envia o site pra Hostinger usandoSamKirkland/FTP-Deploy-Action@v4.3.5. - Remote GitHub:
origin=https://github.com/LucasKalil-Programador/world-2026-hub.git(branchmaster). 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 depublic_html, então oserver-diré relativo a ele — NÃO prefixarpublic_html/(causapublic_html/public_html/worldcup2026). O destino final no disco épublic_html/worldcup2026/.excluderemove 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.jsonno 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
protocolparaftp. 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/*.svgstripped of the card chrome: removed the.cardbackground rect, top/bottom accent bars,name/city/cap<text>elements, divider<line>s, and the now-unused.card/.name/.city/.cap/.divstyle rules —stadiums.jsalready renders name/city/capacity as HTML, so the SVG no longer duplicates them. viewBoxcropped to just the<g>illustration (~10px padding) per file, and the fixedwidth="300" height="400"attrs removed so the SVG's intrinsic aspect ratio matches itsviewBox— 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 onlystruct/thin/hair/concrete/stands/canopy/void/pitch/pline/acc/accs/green/ribs/louverclasses +fritpattern → single<g>with the illustration, cropped tightly. Aim for a viewBox aspect ratio near 4:3 (~1.2-1.3), to match.stadium-img'saspect-ratio: 4/3.
Stadium card image aspect ratio (2026-06-14)
.stadium-img(inassets/css/style.css) usesaspect-ratio: 4/3, not16/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/9forcedobject-fit: coverto crop ~28% of the image height, slicing through rounded-rect/curved illustration paths (visible as a "diagonal" cut on NRG).4/3shows 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.jsloadData()appends?v=${DATA_VERSION}to everydata/*.jsonfetch (DATA_VERSIONconstant near the top of the file, currently'2026-06-13-rev1'). Fixes production browsers/Hostinger caching staleresults.jsonafter 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. FormatYYYY-MM-DD-revN; incrementrevNfor 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 só de labels nolangchange. Roadmap A–J com portões de aprovação (A–F entregam a tela só 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 6ª aba
statsimplementada de forma incremental (plano emC:\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.csslinkado no<head>; botão+painelpanel-stats/#stats-rootnoindex.html;'stats'adicionado aTABSemapp.js;initStats()chamado notrydeinit(); chavesnav.stats+stats.*(EN/PT) emi18n.js. - Filosofia (decidida via /grill-me): agregados correntes "até agora", só
status==='finished'(consistente comcomputeStandings); "X de 104" é moldura, não lacuna.statsopcional (posse/chutes/cartões) entra com gating por-jogo (aggregateTeamsignora jogo sem o campo). Sem polling — recomputa no load + re-render de labels nolangchange(modelo memoizado emlet 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/COLUMNSno topo destats.js). 10 colunas ordenáveis (J/V/E/D/GP/GC/SG/Pts/G·J/CS; rótulos curtos reusamstandings.*, os 2 novos têmtitle). 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 —computeLeaderssó consideraplayed>0. EstadosortKey/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-wrapscroll-x com#+Seleçãoposition: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).
penaltiesainda 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
titlenativo), nome + definição onde ajuda, e legenda compacta só no mobile. initTooltips()emapp.js(chamado noinit()): um único balão.app-tooltipposition:fixed, via delegação de eventos emdocument(mouseover/focusinmostram,mouseout/focusout/scrollescondem). Sobrevive a re-renders das tabelas e nunca é cortado por overflow/stacking (motivo de não usar::afterdentro do.stats-table-wrapque temoverflow-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, já que o balão é visual). Textos emi18n.jsno namespacetip.*(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:noneno desktop,flexem≤600px(estilo emstats.css). Uma por tabela:legendHTML()emstats.js(usaCOLUMNS) e emgroups.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-legendvalem também na aba Grupos).
How to add a UI label
- Add the key to both
enandptdicts inassets/js/i18n.js. - Use
t("key")at the render site — never hardcode the string.
How to add a new localStorage preference
- Extend the
wc2026_prefsobject shape (document the new field here). - Read/write only via
storage.jsget/set.
How to add a step summary after finishing a build step
- Mark the step
[x] ~~...~~in.agents/TODO.md. - Append any new decisions/gotchas here (never rewrite existing entries).
- Rewrite
project-map.mdif structure/functions changed. - 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.