# Project Memory — World Cup 2026 Hub Persistent memory for this project. **Read before any significant change.** **Fixed structure (keep this order):** Context · Architecture & Decisions · Gotchas · Operational Runbooks · Stats Screen · Patterns & How-tos · Current State. > **Maintenance rule (set 2026-06-17):** this file holds **durable** knowledge only — > architecture, decisions, gotchas, patterns. Per-match daily-refresh detail lives in **git > commits** (see the commit convention), *not here*. The **Current State** section keeps a rolling > window of the **last 3 refreshes** and is **pruned on each update** (do not append new dated > refresh logs). New *decisions / gotchas / patterns* are appended to their section. Content is kept > in its original language (EN or PT) where it was written that way; new scaffolding is in English. --- ## Context Static web app for the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) — schedule, group standings, interactive knockout bracket with user simulation, stadiums, and a post-tournament stats screen. All content from `data/*.json`. Started 2026-06-11 from two spec documents; built step-by-step with user approval between steps; now live with **real** WC2026 data, refreshed daily. **What it is:** a **personal/portfolio** piece (visual polish is a primary goal); a **static SPA** (one `index.html`, ES-module vanilla JS, JSON as the only "database"); maintained by **editing JSON only** — code should never need touching to update scores/teams. **What it is not:** no backend, database, build step, bundler, CDN dependency, or framework; no automated tests / linter (explicit spec constraint). **Spec source of truth:** `world-cup-2026-hub-spec-en.md` + `complement-spec-worldcup2026-en.md` (**complement wins on conflict**). --- ## Priority objectives 1. **Spec compliance** — complement spec wins on conflict. 2. **Visual quality** — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade. 3. **Interactive bracket** — hover path highlight, zoom, drag, simulation; the centerpiece feature. 4. **Easy maintenance** — real data drop-in via JSON; `bracket-config.json` is the only structural file edited after the group stage. 5. **Performance/accessibility** — Lighthouse > 90, first render < 2s, JS < 300KB, ARIA + keyboard nav. --- ## Architecture & Decisions ### Stack & module pattern - **Vanilla HTML/CSS/JS ES2022+, ES Modules**, relative paths, no bundler/CDN/framework — spec mandate (GitHub Pages / Hostinger serve static files only). - **EN/PT-BR UI toggle** via `i18n.js`: tiny dict + `t(key)`, persisted in `wc2026_prefs.lang`. Static HTML uses `data-i18n` / `data-i18n-aria` re-applied by `applyI18n()`; dynamic renders call `t()` and listen for `langchange`. Phases via `translatePhase()` (PT: R32 = "16 avos de final"). Default language: `navigator.language` startsWith `pt` → PT, else EN; only persisted on toggle. - **`storage.js`** is the only access path to `localStorage` (`wc2026_*` keys, auto JSON). Holds prefs (`lang`, `lastTab`, `timeMode`), `wc2026_favorites`, `wc2026_simulation`. - **Per-view modules** (`schedule/groups/bracket/stadiums/stats`) + `app.js` entry. **Circular imports `app.js` ⇄ view modules are intentional and safe** in native ESM — all cross-calls happen at render runtime, after every module has evaluated. `stats.js` imports `getBracketTree`, `getFavorites`, `openMatchModal` this way too. - **Custom events on `document`** drive re-renders — each view owns its own: `langchange`, `simchange`, `favchange`, `timemodechange`, `datachange` (live refresh). No shared render loop. ### Data model - **All match times are UTC** in `matches.json`; converted at render by `formatMatchTime(match, stadium, mode)` via `Intl.DateTimeFormat` (`mode` = `"local"` browser tz, or `"stadium"` timezone). `.ics` export depends on this. - **Match ids:** group matches **1–72 = chronological by UTC kickoff** (≠ 6-per-group blocks); **73–104 = FIFA official match numbers** (knockout, carry `bracketRef`). - **Knockout matches carry `bracketRef`, not teams** — resolved at runtime from standings + `bracket-config.json`; rounds after R32 have no config and are generated by **sequential pairing of winners** (indices 0-1 → 0, 2-3 → 1, …). - **Simulation never mutates JSON** — overlay in `localStorage.wc2026_simulation`, keyed by bracketRef (`R32-6: { winner: "FRA", score: "2-1" }`, score home-away). - Team ids are **3-letter uppercase** (`MEX`, `BRA`). Knockout ids: `R32-1`…`R32-16`, `R16-1`…, `QF-1`…, `SF-1`/`SF-2`, `THIRD-PLACE`, `FINAL`. ### Standings (`groups.js`) - **Only `status:"finished"` counts** toward standings (live scores ignored until full-time → stable standings + deterministic bracket resolution). - **Tiebreak:** points → goal difference → goals for → team id alphabetical (stable fallback). - `computeStandings()` (per-group, finished only) and `isGroupFinished()` are exported and reused by `bracket.js` / `stats.js` (no recompute). ### Bracket (`bracket.js`) - **Tree is language-neutral**: slots are `{ teamId }` or `{ ph: {kind,…} }`; placeholder text is produced at render time by `slotDisplay()`, so language switches never invalidate the tree. - **Tree is cached**; `invalidateBracket()` drops it (simulation overlay + live refresh). - **`resolveBracketTeams(matchOrRef)`** → `{ home, away }` of `{ team: Team|null, label }` for any match (group or knockout); reused by schedule cards, modal, and search/team filters (so knockout matches become searchable/filterable once resolved). `getBracketTree()` → `{ rounds, third, nodesByRef, champion }`. - **CSS connectors depend on an equal-height invariant:** all columns share height with `flex:1` slots, so pair children sit at 25%/75% and the next node at 50%; pure-CSS stubs meet exactly. Column gap = 2 × stub (44px desktop / 36px ≤767). **Breaking equal height breaks the lines.** - **Simulation:** `decide()` applies only real finished results; `applySimulation()` overlays user picks afterwards and **never overrides a real result** (so `simulated:false` ⇒ real). Stale entries (winner no longer resolved) are silently ignored. Eligible nodes (both teams resolved, real result still `scheduled`) get dashed blue borders + a SIM chip. - **Interactions:** full-path highlight computed from ref arithmetic (`floor(i/2)` up, `2i`/`2i+1` down), no tree lookup. Zoom = CSS `transform:scale()` on the canvas + a sized `#bracket-zoom` box, pointer-anchored, clamped 0.4–2; natural size measured lazily (`ensureMeasured()` — panel may be `hidden` at render). Pan/pinch via Pointer Events, `touch-action:none` on the wrap. Drag–click conflict: capture pointer only **after** the >5px threshold (gotcha #6). - **Share/import:** `?prediction=base64(simulation)` via `getShareableLink()` / `loadPredictionFromURL()`; stripped from the URL (`history.replaceState`) whether applied or not; unknown refs rejected wholesale. **Challenge** card scores sim vs real finished knockout results. ### Modal (`modal.js`) - **Native `` + `showModal()`** → focus trap, Esc, `::backdrop` come free. Backdrop click = `event.target === dialog`. Focus restored to the opener on close. Card→modal is **event delegation on `#schedule-root`** (click + Enter/Space), surviving list re-renders. `openMatchModal(matchId)` is the public API for every view. - **Match stats in modal:** optional `stats` field per game in `results.json` (`{ possession, shots, cards }`, home/away following `homeTeam`/`awayTeam`; possession %, total shots, yellow cards). Renders real stats when present, else the `—` placeholder + `modal.statsSoon` note. Adding stats to more games = edit `results.json` only. ### Hero — hybrid clock+JSON (`app.js`) - The home hero advances by the **clock**, not only by the JSON. **`matchState(match, result, now)`** (pure, **exported**, reused by the schedule occurrence filter): `over` if `status==='finished'` **OR** `now ≥ kickoff + window`; `live` if `status==='live'` **OR** `now ≥ kickoff`; else `upcoming`. **JSON always wins** (finished/live force); the clock only advances when JSON lags. Window: `GROUP_WINDOW_MS = 2h` for `Group*`, else `KO_WINDOW_MS = 3h`. - **`findFeaturedMatches(now)`** picks the earliest non-`over` match and returns **all** sharing that exact kickoff → the hero stacks simultaneous group-final matches (last round = 12 pairs, always 2); 1-match render is DOM-identical to before. One persistent **1s `heroTick`**; signature `"id:state"` (joined for the set) → full `renderHero()` on change, else just `updateCountdown()`. `renderHero` is idempotent and re-arms the timer (`if (heroTimer) return`). - Live score shown only if `result.homeScore/awayScore` are non-null; no elapsed-time clock (would be inaccurate on a static site). Badge "Bola rolando!" = key `hero.inProgress` (renamed from `hero.kickoff`); `hero.live` still used by schedule/modal. **Scope: hero only** — Matches/Modal/ Bracket live badges stay JSON-`status`-driven (small transient inconsistency accepted). When the Final goes `over`, the hero is empty (post-Cup home state is a TODO). ### Live data refresh — poll `results.json` without F5 (2026-06-16, Option A⁺) - The data is **not live** — it's a manual push after each match. So poll is **fixed** (`POLL_INTERVAL_MS = 90s`), not state-based. `startResultsPolling()` (called at the end of `init()`, after views register listeners) arms one `setInterval` (`if (pollTimer) return`). `pollResults()` fetches `data/results.json?t=${Date.now()}` with `cache:'no-store'`. (As of 2026-06-18 the initial `loadData()` fetch also uses `?t=Date.now()`; the old hand-bumped `?v=DATA_VERSION` cache-buster was removed — see Cache-busting runbook.) - **Signature = full response text** (catches score corrections, `stats` backfill, penalties — a finished-count signature would miss them). On change: rewrite `data.results` **and rebuild `data.resultByMatchId`** (the derived map), `invalidateBracket()`, dispatch `datachange`. - **3 reinforcements over plain fixed poll:** (1) Page Visibility — interval no-ops when `document.hidden`; `visibilitychange` does an immediate fetch on return. (2) **Stop at the end** — `tournamentOver()` checks `FINAL`'s JSON `status==='finished'` (not clock-`over`, which would stop 3h after kickoff before the score lands) → `stopResultsPolling()`. (3) Content signature (above). - **`bracket-config.json` piggybacks the change event:** the poll fetches only `results.json` each tick, but on a detected change it **also refetches `bracket-config.json`** the same cycle (`data.bracketConfig`) — the one-time 3rd-place fill ships in the same push as a results change, so no per-tick config polling, but the open tab still gets the new `thirdPlaceAssignment` without F5. - **Fan-out:** every view has a `datachange` listener (`app.js`→`renderHome`, `schedule.js`→`renderList`, `groups.js`→`render`, `bracket.js`→`render`, `stats.js`→rebuild model). Not handled (accepted, rare changes): open modal doesn't auto-update; re-render during drag/typing. ### Performance & responsive/a11y - **No `backdrop-filter` on repeated cards** — `.match-card` overrides `.glass` blur (huge paint × 104 cards). Same rule for any future card grid. - **Fixed gradient lives on `body::before` (position:fixed)**, not `background-attachment:fixed` (avoids repainting the background on scroll). - **Breakpoints:** ≤767 (tight; bracket `--node-w:168px`/gap 36px — stub offsets stay at gap/2), 768–1100 (two-band header), **1100+** (single-row header; the flip moved 768→1100, see header pattern), 1440+ (`.container` widens to 1360px). - WAI-ARIA tabs: roving tabindex + Arrow/Home/End in `initTabs()`, focus follows activation. Dialogs get `aria-label` at open; schedule count `aria-live="polite"`; countdown `role="timer"`. Entry animations (panel fade, card stagger) all gated by `prefers-reduced-motion`. --- ## Gotchas 1. **`fetch()` of JSON fails on `file://`** — always serve via `python -m http.server` (Claude Preview `worldcup2026`, port 8126). Symptom: blank app + CORS errors. 2. **GitHub Pages / Hostinger serve under a subpath** — use **relative paths** everywhere (`data/matches.json`, `assets/...`); root-absolute (`/data/...`) 404s in production. 3. **`.ics` requires CRLF line endings** (`calendar.js`) — RFC 5545 mandates `\r\n`; some calendar apps silently reject `\n`. 4. **Third-place slots are `null` until filled** (`bracket-config.json.thirdPlaceAssignment`) — `resolveBracketTeams()` must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever a slot is `null` or its group isn't finished. Symptom if forgotten: crash / "undefined" in R32. 5. **Stale JS modules in the dev browser** — `python -m http.server` sends no cache headers, so browsers heuristically cache ES modules. Hard-reload via `Promise.all(files.map(f => fetch(f, {cache:'reload'}))) → location.reload()`, or DevTools hard reload. 6. **`setPointerCapture` on pointerdown kills element clicks** (`bracket.js`) — capturing retargets the eventual `click`, so delegation never matches → modal/sim clicks die. Capture only after the >5px drag threshold, in `pointermove`, try/catch. **Verify click flows with `preview_click` (trusted input), not `element.click()`.** 7. **Claude Preview screenshots can hang** (tooling, not app) — `preview_eval` keeps working; `preview_stop` + `preview_start` recovers. Verify state via `preview_eval` before suspecting the app. 8. **Claude Preview: resize beyond the native window (~791 CSS px) breaks clicks/screenshots** — viewport emulation desyncs the capture surface. At emulated widths > native, navigate via `preview_eval` + `navigateTo()` and verify geometry via eval/inspect; trust screenshots only at widths ≤ native. `preview_resize preset: desktop` resets it. --- ## Operational Runbooks ### Daily data refresh Follow **`how-refresh-data.md`** (project root) before touching any `data/*.json`. In short: edit `data/results.json` (scores/status, two-source rule, `penalties` only on knockout ids 73–104) → verify in preview → commit (two-commit convention) → push (user's go) → deploy. Frozen files (never edit): `stadiums/teams/groups/bracket-config.round32/assets/code`. `how-update.md` stays as the schema reference for the (completed) mock→real migration. ### `thirdPlaceAssignment` (one-time, after the group stage ~Jun 27–28) When all 72 group matches are `finished`, fill `bracket-config.json.thirdPlaceAssignment` (slot → group **LETTER**, per FIFA's published allocation — never derive it yourself). Each group letter appears in **at most one** slot; unfilled slots stay `null`: | Slot | Feeds (FIFA match) | Allowed groups | |---|---|---| | 1 | M74 (vs Winner E) | A/B/C/D/F | | 2 | M77 (vs Winner I) | C/D/F/G/H | | 3 | M81 (vs Winner D) | B/E/F/I/J | | 4 | M82 (vs Winner G) | A/E/H/I/J | | 5 | M79 (vs Winner A) | C/E/F/H/I | | 6 | M80 (vs Winner L) | E/H/I/J/K | | 7 | M85 (vs Winner B) | E/F/G/I/J | | 8 | M87 (vs Winner K) | D/E/I/J/L | ### Cache-busting (2026-06-18: DATA_VERSION removed) `app.js` `loadData()` appends `?t=${Date.now()}` to every `data/*.json` fetch — same scheme the live-refresh poll already used. **There is no `DATA_VERSION` constant to bump anymore** (removed 2026-06-18); every load gets a unique URL, so Hostinger can never serve a stale `results.json` and the daily refresh has zero cache step. ~~Previously appended `?v=${DATA_VERSION}` (a hand-bumped `YYYY-MM-DD-revN` constant) — retired because the manual bump was easy to forget and `Date.now()` guarantees freshness.~~ Note: **JS/CSS are not versioned** (no build step) — on Hostinger returning visitors may serve stale code until their browser re-fetches; new visitors / hard-refresh see it at once. Accepted. ### App version (footer) Single source of truth: **`assets/js/i18n.js` line 9** — `const APP_VERSION = 'v1.0.X'`. Auto-shown in both EN and PT footers via `t('footer.note')`. Bump after a notable ship (new section, major bugfix, schema change, deploy). Commit e.g. `refactor(footer): bump version to vX.Y.Z`. ### Commit convention (standardized 2026-06-15) Every `/update-worldcup` run = **two commits** (full spec in `how-refresh-data.md`): 1. **Data commit** (`results.json`, + `bracket-config.json` on the 3rd-place day): - 1 game → `data: update DD/MM/YYYY HH:MM HOMExAWAY HxA` - N games → `data: update DD/MM/YYYY — N jogos` + one body line per game. - Penalties (knockout only): suffix `(pen HxA)`. 2. **Docs commit:** `docs: log daily refresh DD/MM/YYYY` (`.agents/` + TODO). Rules: `DD/MM/YYYY` + `HH:MM` are the match's **UTC** kickoff (as in `matches.json`); codes = 3 uppercase letters; separator lowercase `x`. `.agents/` is excluded from the FTP deploy → keeping it a separate commit keeps the data commit's diff clean. ### Deploy — Hostinger via FTP (GitHub Actions, 2026-06-14) - `.github/workflows/deploy.yml`: every `push` to `master` (or `workflow_dispatch`) deploys via `SamKirkland/FTP-Deploy-Action@v4.3.5` (`protocol: ftps`, `port: 21`, `local-dir: ./`, `server-dir: worldcup2026/`). - **origin** = `https://github.com/LucasKalil-Programador/world-2026-hub.git` (branch `master`). Push via Windows credential manager (**gh CLI is NOT installed** on this machine). - **Secrets** (repo → Settings → Secrets → Actions): `FTP_SERVER`, `FTP_USERNAME`, `FTP_PASSWORD` (from Hostinger hPanel). Without them the workflow fails. - **Gotcha:** the Hostinger FTP account logs in **already inside `public_html`**, so `server-dir` is relative to it — do **not** prefix `public_html/` (causes `public_html/public_html/...`). Final path: `public_html/worldcup2026/`. If FTPS is rejected, switch `protocol` to `ftp`. - `exclude` removes `.git*`, `.github/`, `.agents/`, `README.md`, `how-*.md`, `*-en.md` specs — only `index.html` + `assets/` + `data/` reach the site. New `data/` / `manifest.json` / `assets/icons/` files **are** deployed. Incremental sync state (`.ftp-deploy-sync-state.json`) lives only on the server — don't commit it. ### Real-data migration (DONE 2026-06-12) All 6 `data/*.json` hold real WC2026 data (sources: Wikipedia per-group + knockout articles, cross-checked vs ESPN/FOX/olympics.com). **Stadiums trimmed 30 → 16**; cities use FIFA host-city names ("New York/New Jersey", "San Francisco Bay Area", "Boston") — `matches.json` and `stadiums.json` must match exactly. **bracket-config app-order ↔ FIFA mapping:** R32-1..16 = FIFA matches 74, 77, 73, 75, 83, 84, 81, 82, 76, 78, 79, 80, 86, 88, 85, 87 (so the app's sequential pairing reproduces the official R16/QF/SF progression). Re-verify near Jul 6: **match 94** (R16, Lumen Field) kickoff was single-source (Wikipedia 17:00 PDT vs an ESPN summary implying 14:00 PDT). --- ## Stats Screen (`feature/stats-final-screen`) Full post-Cup stats screen built from **`.agents/stats-screen-plan.md`** (stages A–J). The pure-UI build (**A–D + F**, E skipped) **+ J round 1 polish** was **merged to `master` 2026-06-17** and is live. `master` keeps the partial screen + daily refreshes. Live sub-nav chips: **Overview · Teams · Records · Comparator**. Data-layer stages (G/H/I) + a second J polish remain for near/after the Cup. ### Plan & first-order requirement Plan generated 2026-06-14 via a 5-sub-agent workflow; scope = **4 data layers** (✅ existing · 🟡🧩 cheap additions · 🔴 player data · 📝 editorial). **First-order requirement — graceful degradation:** when a datum is missing, the UI must not break **nor reveal to the end user that anything is missing** — no `—`, no empty cards, no "coming soon". A datum/section renders only when complete enough to be authoritative; otherwise it is **removed from the DOM** (not hidden). Sub-nav chips of empty sections disappear too; `loadData()` tolerates a missing optional file (empty default, not an exception). ### Stage A — degradation engine + scaffolding - **Fault-tolerant `loadData()`:** the 6 core files still **throw** on failure (fatal); 6 optional layers (`players`, `player-events`, `awards`, `keeper-stats`, `curiosities`, `all-time-baselines`) load via `loadOptional(name, fallback)` → absent/404 returns the empty default **silently**, warns only on a present-but-malformed file. Core + optional fetch concurrently. - **Section-gating — `SECTIONS` registry in `stats.js`:** each section `{ id, navKey, available(model), body(model) }` renders (and shows its chip) only when `available` holds; else it is **omitted from the DOM entirely** and the nav never points at emptiness. - **Sticky scrollspy sub-nav:** hero + `` (anchor chips) + one section per available section + footer. Chips are `` but **`preventDefault` + `scrollIntoView`** — they **NEVER set `location.hash`** (the tab router listens on `hashchange`; a real `#stats-teams` would route to an unknown tab → bounce to Home). Scrollspy is **position-based** (rAF-throttled `scroll` reading `getBoundingClientRect`) + an explicit "at page bottom → last section" rule (an IntersectionObserver band left a short final section unlit). - **`--header-h` CSS var** kept live by `trackHeaderHeight()` (`ResizeObserver` on the variable-height sticky header). Sub-nav sticks at `top: var(--header-h)`; sections use `scroll-margin-top`. - **Media fallback (§0.3):** `flagImg(team,w,h)` emits the flag with `data-monogram=""`; a one-time capture-phase `error` listener replaces a broken flag with a 3-letter `` — never a broken-image icon. ### Stage B — verdict hero + goals-by-round - `heroHTML()` → `model.verdict ? verdictHeroHTML() : aggregateHeroHTML()`. The verdict hero (champion + 2/3/4 podium, shared count-up tiles) is **gated on the REAL final:** `computeVerdict()` reads `getBracketTree().nodesByRef.get('FINAL')` and returns `null` unless `status==='finished' && !simulated && winner` (a user's simulated champion never leaks). Falls back to the aggregate "in progress" hero until the final is really finished. - **Goals-by-round chart** (Overview): group stage split into 3 matchdays (`computeGroupMatchdays`: sort each group's 6 fixtures by kickoff, chunk into pairs — `matches.json` has no matchday field) plus each knockout round. Hidden until ≥2 rounds have data. ### Stage C — final ranking 1–48, favorites, team records - **Canonical ranking 1–48 (`assignRanks` → `computeRankTiers`):** primary key is the deepest stage **reached** from REAL knockout results (champion 0 → … → group 7), then points → GD → GF → id. **Real results only** (same `!simulated && finished` gate as the verdict). During groups everyone is tier 7 → it's the global points table; post-knockout the champion is #1 even with fewer points. - **`#` column = canonical rank AND the default sortable header.** The `#` cell always shows the canonical rank regardless of active sort; non-rank sorts fall back to `a.rank - b.rank`. - **Favorite-team row highlight (gold):** `row-fav` when `getFavorites()` includes the team; `favchange` re-renders the table only (favorites aren't in the model). Highlight-only, no stars. - **Team record cards (Teams):** longest win streak (≥2, hidden below) + champion's path (gated on verdict). `stats.js` imports `getFavorites` (storage.js) + `openMatchModal` (modal.js). ### Stage D — Records section + format-48 debuts - `records` section is **always available** (`body: recordsSectionHTML`). Sub-nav = Overview · Teams · Records. **Match-record cards live here:** biggest win (margin) + highest-scoring match (combined goals); high-score card **deduped** when it's the same match as biggest win. (`biggestWin` moved out of Teams into Records for a clean C/D split.) - **"Format debuts" band:** firsts of the 48-team era (48 teams, 104 matches, 12 groups, "Round of 32" via `translatePhase`, 8 best thirds advance, first 48-team champion — lights up post-final from `model.verdict`). Counts come from `getData()`/model, not hardcoded. ### Stage E — SKIPPED (Option B, 2026-06-17) The in-tab 104-match results archive will **not** be built. The Matches tab (`schedule.js`) already lists all 104 with filters/search/sort/occurrence/"My matches"/modal — an in-tab archive would duplicate it. The footer keeps a **"See all matches →"** link (`#stats-see-matches` → `navigateTo('matches')`). The `archive` entry stays `available:()=>false` / `body:()=>''` — a dormant slot, **don't delete the registry line**. If revisited, the lighter "phase-accordion, results-only" variant (Option C) was the recommended shape. ### Stage F — team comparator - `comparator` section, `available:(m)=>m.finishedCount > 0`. Two `