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

284 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Project Memory — World Cup 2026 Hub
Persistent memory for this project. Read this before any significant change.
---
## Context
Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) — schedule, group standings, interactive knockout bracket with user simulation, stadiums. Hosted on GitHub Pages, all content from `data/*.json`. Started 2026-06-11 from two spec documents; built step-by-step with user approval between steps.
### What this project is
- A **personal/portfolio** piece — visual polish (glassmorphism, animations) is a primary goal, not a nice-to-have.
- A **static SPA**: one `index.html`, ES-module vanilla JS, JSON as the only "database".
- Maintained by editing JSON only — code should never need touching to update scores/teams.
### What this project is **not**
- No backend, no database, no build step, no bundler, no CDN dependencies, no frameworks.
- No automated tests, no linter (explicit spec constraint).
- Not real-data-complete: ships with mock data (fictional teams) to be replaced later.
---
## Priority objectives
1. **Spec compliance** — both spec files define scope; `complement-spec-worldcup2026-en.md` wins on conflict.
2. **Visual quality** — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
3. **Interactive bracket** — hover path highlight, zoom, drag, simulation mode; the centerpiece feature.
4. **Easy maintenance** — real data drop-in via JSON; `bracket-config.json` is the only file edited after group stage.
5. **Performance/accessibility** — Lighthouse > 90, first render < 2s, JS < 300KB, ARIA + keyboard nav.
---
## Technical decisions and rationale
### Stack
- **Vanilla HTML/CSS/JS ES2022+, ES Modules** spec mandate; GitHub Pages serves static files only. Frameworks/bundlers explicitly forbidden.
- **EN/PT-BR UI toggle via `i18n.js`** (user decision 2026-06-11) not in spec; spec UI examples are EN, user wants both. Tiny dict + `t(key)`, persisted in `wc2026_prefs.lang`. Alternative (EN only) rejected by user.
- **`storage.js` pulled forward to step 2** (spec places it in phase 12) prefs (`lang`, `lastTab`) are needed from the base-layout step; building it late would mean refactoring.
### Data model
- **All match times in UTC** in `matches.json`; converted at render via `Intl.DateTimeFormat` (`formatMatchTime`). `.ics` export depends on this.
- **Knockout matches carry `bracketRef` instead of teams** teams resolved at runtime from standings + `bracket-config.json`; rounds after R32 have no config, generated by sequential pairing of winners (indices 0-1 0, 2-3 1, …).
- **Simulation never mutates JSON** overlay stored in `localStorage` `wc2026_simulation`, keyed by bracket match ids (`R32-1`, `QF-2`, `FINAL`, …).
### Mock data design (2026-06-11, step 1)
- **Real country names, fictional results** generated deterministically (seed 2026), script deleted after run; data is now static JSON.
- **State crafted to test both bracket modes:** matchdays 1-2 all `finished`; matchday 3 `finished` for groups AF, `scheduled` for GL (match 61, Group G, is `live`). So R32 slots fed by AF resolve to real teams; GL and all `third` slots show placeholders.
- **Knockout results:** `R32-2` (match 74) finished 1-1 + penalties 4-3; `R32-4` (match 76) finished 2-0. Everything else `scheduled` with `null` scores `results.json` has an entry for **all 104 matches**.
- **Image paths:** `flag`/`image` JSON values are relative to `assets/images/` (e.g. `flags/mex.svg`, `stadiums/azteca.svg`).
- **Opener** (match 1) is MEX at Estadio Azteca 2026-06-11; **final** (match 104) at MetLife 2026-07-19.
### Base layout decisions (2026-06-11, step 2)
- **Hero priority: live > next scheduled** — a live match replaces the countdown with the score + pulse badge. Mock data keeps match 61 permanently `live`, so the countdown only shows if that status is changed (verified working by temporarily setting it to `scheduled`).
- **i18n mechanics:** static HTML uses `data-i18n` / `data-i18n-aria` attributes re-applied by `applyI18n()`; dynamic renders call `t()` and listen for the `langchange` event on `document`. Phases translate via `translatePhase()` (PT: R32 = "16 avos de final").
- **Tab routing:** hash (`#matches`) + `wc2026_prefs.lastTab`, `history.replaceState` to avoid history spam; precedence on load: hash → lastTab → home.
- **Default language:** `navigator.language` startsWith `pt` → PT, else EN; only persisted when the user clicks the toggle.
- **Preview server:** `.claude/launch.json` at `R:\lucas-kalil\Projects\` defines `worldcup2026` (python http.server, port 8126) for the Claude Preview panel.
### Schedule + performance decisions (2026-06-11, step 3)
- **`schedule.js``app.js` circular import is intentional** — `app.js` calls `initSchedule()`, `schedule.js` imports `getData`/`formatMatchTime`/`flagSrc` back. Safe in native ESM because all calls happen after both modules evaluate; keep this pattern for `groups.js`/`bracket.js`/`modal.js`.
- **Filter UX:** toolbar is rebuilt only on init/langchange/clear (state restored programmatically); list re-renders on every filter change. Filter state is in-memory only (not persisted) by design.
- **Knockout cards show "TBD"** until step 7 swaps `teamColumnHTML`'s lookup to `resolveBracketTeams()`.
- **Perf: no `backdrop-filter` on repeated cards** — `.match-card` overrides `.glass` blur (huge paint cost × 104 cards, invisible over the smooth gradient). Same rule applies to any future card grid (stadiums, bracket).
- **Perf: fixed gradient lives on `body::before` (position: fixed)**, not `background-attachment: fixed` (the latter repaints the whole background on scroll).
### Standings decisions (2026-06-11, step 4)
- **Only `status: "finished"` matches count toward standings** — live scores are ignored until full-time (keeps standings stable and bracket resolution deterministic).
- **Tiebreak order:** points → goal difference → goals for → team id alphabetical (stable fallback). Verified against an independent Python computation for groups A and G.
- **`computeStandings()` / `isGroupFinished()` exported from `groups.js`** — bracket.js (step 7) must import these instead of recomputing.
### Stadiums decisions (2026-06-11, step 5)
- **`stadiums.js` module added** — spec §4 has no module for the stadiums view; a dedicated view module keeps the per-view pattern (schedule/groups/bracket) instead of growing `app.js`.
- **Cross-link:** "View matches" on a stadium card calls `setStadiumFilter(name)` (exported by `schedule.js`, resets other filters) + `navigateTo('matches')` (exported by `app.js`).
### Modal decisions (2026-06-11, step 6)
- **Native `<dialog>` + `showModal()`** — focus trap, Esc-to-close and `::backdrop` come free; no custom trap code. Backdrop click detected via `event.target === dialog` (content is in a padded inner div).
- **Focus restore:** opener element saved in `openMatchModal()` and re-focused on `close`.
- **Card → modal wiring is event delegation on `#schedule-root`** (click + Enter/Space keydown), so it survives list re-renders. Cards got `tabindex="0" role="button" aria-label` already in this step.
- **`openMatchModal(matchId)` is the public API** — bracket (step 7) and any future view should call it rather than building their own.
### Bracket decisions (2026-06-11, step 7)
- **Tree model is language-neutral**: slots are `{ teamId }` or `{ ph: { kind, … } }`; placeholder text is produced at render time by `slotDisplay()` so language switches never invalidate the tree.
- **Tree is cached** in `bracket.js`; `invalidateBracket()` exists for the simulation overlay (step 9). Results are static per page load, so nothing else invalidates it.
- **`resolveBracketTeams(matchOrRef)`** accepts a match object (group or knockout) or a bracketRef string and always returns `{ home, away }` as `{ team: Team|null, label: string }` — schedule cards, modal, and search/team filters all consume this, so knockout matches become searchable/filterable once resolved.
- **Connector geometry**: all bracket columns share equal height with `flex: 1` slots, so pair children sit at 25%/75% and the next round's node at 50% — pure-CSS connectors (`::before`/`::after` stubs + pair vertical) meet exactly. Column gap 44px = 22px out-stub + 22px in-stub. Breaking the equal-height invariant breaks the lines.
- **Final column** holds champion box (top), FINAL (middle, aligns with SF pair), third-place block (bottom); its champion/third slots suppress the incoming connector stub.
### Bracket interaction decisions (2026-06-11, step 8)
- **"Full path" on hover/focus** = hovered node + its entire feeder subtree + its winner's route to the FINAL; THIRD-PLACE lights both SFs. Non-path nodes dim via `.has-path` on the canvas. Computed from ref arithmetic (`floor(i/2)` up, `2i/2i+1` down), no tree lookup.
- **Zoom = CSS `transform: scale()` on the canvas + a `#bracket-zoom` box sized to `natural × scale`** (transform doesn't affect layout, so the box gives the scroll container the right scrollable area). Pointer-anchored: scroll adjusted so the point under the cursor stays put. Clamped 0.42.
- **Natural canvas size is measured lazily** (`ensureMeasured()`) because the bracket panel can be `hidden` at render time (offsetWidth 0).
- **Pan + pinch via Pointer Events** with `touch-action: none` on the wrap (we own all gestures there; page scroll over the bracket is intentionally captured, like a map widget).
- **Dragclick conflict:** >5px movement sets a flag; a capture-phase click listener on the wrap swallows the click that ends a drag. The flag resets on the next `pointerdown`, so synthetic `el.click()` without pointerdown can be falsely suppressed in tests — dispatch pointerdown/up first.
- **Zoom level survives langchange re-renders** (module-level `view.scale`) but intentionally not reloads (not in prefs).
### Simulation decisions (2026-06-11, step 9)
- **Separation of concerns in the tree:** `decide()` applies only real finished results; `applySimulation()` overlays user picks afterwards and never overrides a real result. Stale entries (winner no longer among the resolved teams) are silently ignored — same validation the prediction import (step 12) will use.
- **Sim UX:** "Simulation" toggle in the bracket toolbar; eligible nodes (both teams resolved, real result still `scheduled`) get dashed blue borders; clicking one opens a small native `<dialog>` picker. An unequal score auto-selects the winner; a draw requires an explicit pick (penalties implied). Empty score defaults to 1-0 for the picked winner.
- **Storage format** is exactly the complement-spec shape: `wc2026_simulation = { "R32-6": { winner: "FRA", score: "2-1" } }` keyed by bracketRef, score oriented home-away.
- **`simchange` custom event** fires after any pick/reset; `schedule.js` listens and re-renders so simulated teams appear on knockout cards too (intentional leak — resolved tree is the single source).
- **Simulated nodes** show a blue "SIM" corner chip and blue scores; the modal shows none of this (it reads real results only).
### Responsive/a11y decisions (2026-06-11, step 10)
- **Breakpoints:** ≤767 (tight spacing, bracket `--node-w: 168px`/gap 36px — connector stub offsets must stay at gap/2, overridden in the same media query), 7681439 (single-row header, centered menu), 1440+ (container widens to 1360px).
- **Tabs follow the WAI-ARIA pattern:** roving tabindex + ArrowLeft/Right/Home/End in `initTabs()`; focus follows activation.
- **Dialogs get `aria-label`** set at open time (match name + phase); schedule count is `aria-live="polite"`; countdown has `role="timer"` + label.
- **Entry animations:** every unhidden `.panel` fades in; card grids (`.match-grid/.groups-grid/.stadiums-grid > *`) slide up with a 45ms stagger on the first 6 children, 260ms for the rest. All killed by `prefers-reduced-motion`.
### Extra features decisions (2026-06-12, step 12 — done before step 11 at user request)
- **Favorites:** single global capture-phase click delegation in `app.js` handles every `.fav-btn` (schedule, groups, modal) and dispatches `favchange`; each view re-renders itself. Stars never trigger the card/modal click (guard via `closest('.fav-btn')`). Bracket shows highlight only (no stars — nodes too small). Favorite involvement = gold left border.
- **`getFavoriteMatches(matches, favorites)`** lives in `bracket.js` (needs `resolveBracketTeams`), imported by `schedule.js` for the "My matches" filter.
- **Time mode:** header `#time-toggle` flips `wc2026_prefs.timeMode` and dispatches `timemodechange`; `formatMatchTime()` already defaulted to the pref, so views just re-render.
- **Challenge:** sim entries for real-finished matches can't be created in the UI (locked) but old ones persist in storage — that's by design: predictions are made while matches are `scheduled`, then scored when results land. Card renders only when ≥1 finished knockout match exists.
- **Share/import:** `?prediction=` is stripped from the URL via `history.replaceState` whether applied or not (prevents re-prompt loops). Declining `confirm()` keeps local picks; unknown refs are rejected wholesale.
- **`.ics`:** RFC 5545 TEXT escaping (`\,` etc.) applied even though the spec template shows raw commas — RFC compliance wins; verified output imports with CRLF-only endings.
- **Custom events now in play:** `langchange`, `simchange`, `favchange`, `timemodechange` — all on `document`; views own their re-renders.
### Build complete (2026-06-12, step 11 — all 12 steps done)
- README is the user-facing manual (run, deploy, JSON maintenance, localStorage keys, acceptance checklist). Keep it in sync when data formats change.
- **Verified at completion:** spec §18 criteria all pass; JS = 74 KB total (budget 300 KB); no root-absolute paths (GitHub Pages safe). **Not yet verified:** Lighthouse > 90 (needs a deployed URL or local Lighthouse run); actual GitHub Pages deploy.
- No commits made yet — repo initialized but empty; commit when the user asks.
### Workflow
- **12-step build plan with approval gates** — user approves each step before the next starts; summary after each step. Plan: `C:\Users\Lucas\.claude\plans\read-r-lucas-kalil-projects-web-worldcup-goofy-meerkat.md`.
- **Git repo, commits only when the user asks.**
---
## Known gotchas
### 1. `fetch()` of JSON fails on `file://`
**Where:** any local testing of `index.html`
**Why:** browsers block `fetch` of local files (CORS/origin rules)
**Symptom if forgotten:** blank app, console CORS errors, wasted debugging
**Solution:** always serve via `python -m http.server` (or any static server) from the project root
### 2. `.ics` requires CRLF line endings
**Where:** `assets/js/calendar.js`
**Why:** RFC 5545 mandates `\r\n` between lines; some calendar apps reject `\n`
**Symptom if forgotten:** exported event silently fails to import in Outlook/Apple Calendar
**Solution:** join VCALENDAR lines with `\r\n` explicitly
### 3. Third-place slots are `null` until defined
**Where:** `data/bracket-config.json``thirdPlaceAssignment`
**Why:** the 8 best third-place teams are only known after the group stage
**Symptom if forgotten:** crash or "undefined" team names in R32 rendering
**Solution:** `resolveBracketTeams()` must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever a slot is `null` or the group isn't finished
### 4. Claude Preview screenshots can hang (tooling, not app)
**Where:** Claude Preview panel during verification
**Why:** the preview window's screenshot pipeline occasionally gets stuck; `preview_eval` keeps working
**Symptom if forgotten:** wasted debugging hunting a nonexistent app freeze
**Solution:** `preview_stop` + `preview_start` recovers it; verify state via `preview_eval` first before suspecting the app
### 5. Stale JS modules in the dev browser
**Where:** any JS edit while previewing via `python -m http.server`
**Why:** the server sends no cache headers, so browsers heuristically cache ES modules; a normal reload can keep serving old code
**Symptom if forgotten:** "module does not provide an export" errors or old behavior despite correct code on disk
**Solution:** `Promise.all(files.map(f => fetch(f, { cache: 'reload' })))` then `location.reload()`, or DevTools hard reload
### 6. `setPointerCapture` on pointerdown kills element clicks
**Where:** `assets/js/bracket.js` drag/pan handling on `#bracket-wrap`
**Why:** capturing a pointer retargets the eventual `click` event to the capture element, so delegation via `event.target.closest('.bracket-match')` never matches — modal and simulation clicks silently die
**Symptom if forgotten:** bracket nodes unclickable with real input while synthetic `el.click()` tests still pass
**Solution:** capture only after the drag threshold (>5px) is exceeded, inside `pointermove`, wrapped in try/catch. **Always verify click flows with `preview_click` (trusted input), not `element.click()`.**
### 7. GitHub Pages serves under a subpath
**Where:** all asset/data URLs in `index.html` and JS `fetch` calls
**Why:** project pages live at `https://<user>.github.io/<repo>/`, so root-absolute paths (`/data/...`) break
**Symptom if forgotten:** works locally, 404s on GitHub Pages
**Solution:** use relative paths (`data/matches.json`, `assets/...`) everywhere
### 8. Claude Preview: resize beyond the native window breaks clicks/screenshots
**Where:** Claude Preview panel, `preview_resize` wider than the native window (~791 CSS px)
**Why:** viewport emulation desyncs the capture/click surface from the page (screenshots show the page squeezed in the left half; `preview_click` lands on wrong coordinates and silently does nothing)
**Symptom if forgotten:** "clicks don't work" / half-black screenshots at desktop widths, wasted debugging — the app itself is fine (`preview_eval` geometry confirms)
**Solution:** at emulated widths > native, navigate via `preview_eval` + exported `navigateTo()` and verify geometry via eval/inspect; trust screenshots only at widths ≤ native. `preview_resize preset: desktop` resets to native and fixes clicks.
---
## Patterns for future changes
### Match stats no modal (2026-06-14)
- **Campo opcional `stats` em `results.json`** por jogo: `{ possession: {home,away}, shots: {home,away}, cards: {home,away} }` (`home`/`away` seguem `homeTeam`/`awayTeam` de `matches.json`; valores = posse em %, finalizações totais, cartões amarelos). Primeiro preenchido no jogo 6 (BRA 11 MAR) como teste do deploy: posse 51/49, finalizações 12/14, cartões 2/0 (fontes ESPN/Opta/Sofascore).
- **`modal.js` renderiza stats reais quando `result.stats` existe**; senão mantém o placeholder com `—` + nota `modal.statsSoon` (backward-compatível, verificado nos jogos 6 com stats e 4 sem). `statRow(home, label, away)` agora recebe valores; antes só o label.
- Reusa as chaves i18n já existentes: `modal.possession` / `modal.shots` / `modal.cards`. Adicionar stats a mais jogos = só editar `results.json`, nenhum código muda.
### How to update real-world data (scores, schedule)
Follow `how-refresh-data.md` (project root). In short:
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
2. Once group stage ends: fill `data/bracket-config.json``thirdPlaceAssignment` (slot → group letter). Nothing else changes.
### Real-data migration (2026-06-12)
- `how-update.md` (project root) is the full runbook for replacing mock `data/*.json` with real World Cup 2026 data: file-by-file schemas, order of operations (stadiums → teams → groups → bracket-config.round32 → matches → results → thirdPlaceAssignment), and a cross-file integrity checklist (group membership, id ranges, bracketRef uniqueness, stadium name/city matches).
- Flags one open decision: `stadiums.json` has 30 entries (original bid shortlist) vs. the 16 venues actually used by the real tournament — confirm with user whether to trim before/while editing `matches.json`.
### Real-data migration DONE (2026-06-12)
- **All 6 `data/*.json` files now hold real WC2026 data.** Sources: Wikipedia per-group + knockout-stage articles (primary; local kickoff times with explicit UTC offsets), cross-checked vs ESPN/NBC/FOX/olympics.com/lumenfield.com. Full smoke test + desktop/mobile layout pass, console clean.
- **Stadiums trimmed 30 → 16** (user decision). Cities use FIFA host-city names ("New York/New Jersey", "San Francisco Bay Area", "Boston"), not suburb names (East Rutherford, Santa Clara, Foxborough) — `matches.json` and `stadiums.json` must keep matching exactly.
- **Match ids:** group matches 172 are *chronological by UTC kickoff* (≠ 6-per-group blocks); knockout ids 73104 are FIFA's official match numbers.
- **bracket-config app-order ↔ FIFA mapping:** R32-1..16 = FIFA matches 74, 77, 73, 75, 83, 84, 81, 82, 76, 78, 79, 80, 86, 88, 85, 87 (ordering chosen so the app's sequential pairing reproduces the official R16/QF/SF progression).
- **Third-place slots → allowed groups** (for filling `thirdPlaceAssignment` after the group stage, per official draw): slot 1 (M74) A/B/C/D/F · slot 2 (M77) C/D/F/G/H · slot 3 (M81) B/E/F/I/J · slot 4 (M82) A/E/H/I/J · slot 5 (M79) C/E/F/H/I · slot 6 (M80) E/H/I/J/K · slot 7 (M85) E/F/G/I/J · slot 8 (M87) D/E/I/J/L. Each group letter may appear in only one slot.
- **Results as of 2026-06-12:** ids 13 finished (MEX 20 RSA, KOR 21 CZE, CAN 11 BIH); USAPAR (id 4) kicked off 01:00 UTC Jun 13 — first thing to update next session.
### Daily refresh (2026-06-13)
- **Results updated through match id 6** (matchday 1 complete):
- id 4: USA 41 PAR (Group D) — confirmed FIFA + Yahoo/ESPN
- id 5: QAT 11 SUI (Group B) — confirmed FOX Sports + ESPN (Khoukhi 94th-min header)
- id 6: BRA 11 MAR (Group C) — confirmed FOX Sports + NBC Sports (Saibari for MAR, Vinicius Jr. for BRA)
- Verified standings in Groups view: Group B shows Qatar/Switzerland each 1 pt; Group C shows Brazil/Morocco each 1 pt; Group D confirms USA 3 pts (W 4-1), Paraguay 0 pts.
- **Single-source caveat:** R16 match 94 (Jul 6, Lumen Field) time 17:00 PDT per Wikipedia; one ESPN summary implied 14:00 PDT. Re-verify when R16 nears.
- Next: matches 78 scheduled Jun 14 (HAISCO, AUSTUR, both Group stage). Continue daily routine per `how-refresh-data.md`.
### Daily refresh (2026-06-14)
- **Results updated through match id 7** (HAISCO):
- id 7: HAI 01 SCO (Group C) — confirmed Outlook India + VAVEL USA (McGinn 28')
- Verified standings in Groups view: Group C now shows Scotland 1W 3pts, Brazil/Morocco 1D 1pt each, Haiti 1L 0pts.
- Next: matches 8+ scheduled Jun 14 (AUSTUR onwards). Continue daily routine.
### Daily refresh (2026-06-14 — stats backfill)
- **Stats added to matches 17:**
- id 1 (MEX 20 RSA): possession 60/40, shots 16/2, cards 1/4 — sources: Yahoo Sports box score, ESPN
- id 2 (KOR 21 CZE): possession 62/38, shots 16/4, cards 1/0 — sources: ESPN, Opta Analyst
- id 3 (CAN 11 BIH): possession 61/39, shots 13/8, cards 1/3 — sources: ESPN, VAVEL USA
- id 4 (USA 41 PAR): possession 65/35, shots 16/9, cards 1/5 — sources: ESPN, Opta Analyst
- id 5 (QAT 11 SUI): possession 44/56, shots 8/11, cards 2/1 — sources: ESPN, Opta Analyst (possession adjusted to 100%)
- id 6 (BRA 11 MAR): already had stats from prior session (possession 51/49, shots 12/14, cards 2/0)
- id 7 (HAI 01 SCO): possession 48/52, shots 1/2, cards 1/0 — sources: ESPN, Sofascore
- Verified in preview: all seven matches now display stats in modal (confirmed via preview_eval). Possession, shots, and card counts render correctly in both EN and PT.
- No new match results to add (matches 8+ still scheduled).
- Next: continue daily routine when matches 8+ complete.
### Daily refresh runbook (2026-06-12)
- **`how-refresh-data.md` (project root) is the runbook for all updates during the tournament** — read it before touching any `data/*.json` from now on. It defines: daily `results.json` routine (scores/status, two-source rule, penalties only on ids 73104), the one-time `thirdPlaceAssignment` fill (~Jun 2728, slot → allowed-groups table), and the frozen files (stadiums/teams/groups/round32/assets/code — never edit).
- `how-update.md` stays as the schema reference for the (completed) mock → real migration; `how-refresh-data.md` supersedes it for day-to-day work.
### CI/CD — deploy automático para Hostinger via FTP (2026-06-14)
- **GitHub Actions** em `.github/workflows/deploy.yml`: a cada `push` em `master` (ou `workflow_dispatch` manual) envia o site pra Hostinger usando `SamKirkland/FTP-Deploy-Action@v4.3.5`.
- **Remote GitHub:** `origin` = `https://github.com/LucasKalil-Programador/world-2026-hub.git` (branch `master`). Push via credential manager do Windows (gh CLI NÃO está instalado nesta máquina).
- **Secrets necessários no repo** (Settings → Secrets and variables → Actions): `FTP_SERVER`, `FTP_USERNAME`, `FTP_PASSWORD` — vêm do hPanel da Hostinger (Files → FTP Accounts). Sem eles o workflow falha.
- **Config do workflow:** `protocol: ftps`, `port: 21`, `local-dir: ./`, `server-dir: worldcup2026/`. **Gotcha confirmado 2026-06-14:** a conta FTP da Hostinger faz login JÁ DENTRO de `public_html`, então o `server-dir` é relativo a ele — NÃO prefixar `public_html/` (causa `public_html/public_html/worldcup2026`). O destino final no disco é `public_html/worldcup2026/`. `exclude` remove do deploy: `.git*`, `.github/`, `.agents/`, `README.md`, `how-*.md`, specs `*-en.md` — só `index.html` + `assets/` + `data/` chegam ao site.
- **Sync incremental:** a action mantém `.ftp-deploy-sync-state.json` no servidor; só reenvia arquivos alterados. Não comitar esse arquivo (vive só no servidor).
- **Gotcha:** se a Hostinger não aceitar FTPS explícito, trocar `protocol` para `ftp`. Se o site ficar em subpasta, lembrar do gotcha #7 (paths relativos) — já está OK no projeto.
### Stadium SVG cleanup (2026-06-14)
- **All 16 `assets/images/stadiums/*.svg` stripped of the card chrome**: removed the `.card` background rect, top/bottom accent bars, `name`/`city`/`cap` `<text>` elements, divider `<line>`s, and the now-unused `.card`/`.name`/`.city`/`.cap`/`.div` style rules — `stadiums.js` already renders name/city/capacity as HTML, so the SVG no longer duplicates them.
- **`viewBox` cropped to just the `<g>` illustration** (~10px padding) per file, and the fixed `width="300" height="400"` attrs removed so the SVG's intrinsic aspect ratio matches its `viewBox` — needed for `.stadium-img { aspect-ratio: ...; object-fit: cover }` to crop correctly instead of showing a letterboxed 3:4 image.
- **White rectangular shapes still visible at the top of some cards (BBVA, Lumen, BMO) are intentional** — they're each stadium's press-box/scoreboard tower (`class="void"`, connected to the bowl by thin lines), not leftover text artifacts. Don't remove them.
- If adding a new stadium SVG, follow this trimmed structure: `<svg viewBox="...">` (no width/height) → `<defs><style>` with only `struct/thin/hair/concrete/stands/canopy/void/pitch/pline/acc/accs/green/ribs/louver` classes + `frit` pattern → single `<g>` with the illustration, cropped tightly. Aim for a viewBox aspect ratio near 4:3 (~1.2-1.3), to match `.stadium-img`'s `aspect-ratio: 4/3`.
### Stadium card image aspect ratio (2026-06-14)
- **`.stadium-img` (in `assets/css/style.css`) uses `aspect-ratio: 4/3`**, not `16/9`. The 16 cropped stadium SVG viewBoxes have natural ratios ~1.07-1.32 (avg ~1.25), much closer to 4:3 (1.333) than 16:9 (1.778) — `16/9` forced `object-fit: cover` to crop ~28% of the image height, slicing through rounded-rect/curved illustration paths (visible as a "diagonal" cut on NRG). `4/3` shows each illustration nearly whole across all 16 cards, including the BBVA/Lumen/BMO press-box towers and the Mercedes-Benz pinwheel.
### Data cache-busting via DATA_VERSION (2026-06-14)
- **`assets/js/app.js` `loadData()` appends `?v=${DATA_VERSION}`** to every `data/*.json` fetch (`DATA_VERSION` constant near the top of the file, currently `'2026-06-13-rev1'`). Fixes production browsers/Hostinger caching stale `results.json` after a daily refresh — `cache: 'reload'` only helps the developer's own browser, not real visitors.
- **Must be bumped on every data refresh** — added as step 4 of the daily routine in `how-refresh-data.md`. Format `YYYY-MM-DD-revN`; increment `revN` for same-day re-edits.
### 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.