# Project Memory — World Cup 2026 Hub Persistent memory for this project. Read this before any significant change. --- ## Context Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) — schedule, group standings, interactive knockout bracket with user simulation, stadiums. Hosted on GitHub Pages, all content from `data/*.json`. Started 2026-06-11 from two spec documents; built step-by-step with user approval between steps. ### What this project is - A **personal/portfolio** piece — visual polish (glassmorphism, animations) is a primary goal, not a nice-to-have. - A **static SPA**: one `index.html`, ES-module vanilla JS, JSON as the only "database". - Maintained by editing JSON only — code should never need touching to update scores/teams. ### What this project is **not** - No backend, no database, no build step, no bundler, no CDN dependencies, no frameworks. - No automated tests, no linter (explicit spec constraint). - Not real-data-complete: ships with mock data (fictional teams) to be replaced later. --- ## Priority objectives 1. **Spec compliance** — both spec files define scope; `complement-spec-worldcup2026-en.md` wins on conflict. 2. **Visual quality** — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade. 3. **Interactive bracket** — hover path highlight, zoom, drag, simulation mode; the centerpiece feature. 4. **Easy maintenance** — real data drop-in via JSON; `bracket-config.json` is the only file edited after group stage. 5. **Performance/accessibility** — Lighthouse > 90, first render < 2s, JS < 300KB, ARIA + keyboard nav. --- ## Technical decisions and rationale ### Stack - **Vanilla HTML/CSS/JS ES2022+, ES Modules** — spec mandate; GitHub Pages serves static files only. Frameworks/bundlers explicitly forbidden. - **EN/PT-BR UI toggle via `i18n.js`** (user decision 2026-06-11) — not in spec; spec UI examples are EN, user wants both. Tiny dict + `t(key)`, persisted in `wc2026_prefs.lang`. Alternative (EN only) rejected by user. - **`storage.js` pulled forward to step 2** (spec places it in phase 12) — prefs (`lang`, `lastTab`) are needed from the base-layout step; building it late would mean refactoring. ### Data model - **All match times in UTC** in `matches.json`; converted at render via `Intl.DateTimeFormat` (`formatMatchTime`). `.ics` export depends on this. - **Knockout matches carry `bracketRef` instead of teams** — teams resolved at runtime from standings + `bracket-config.json`; rounds after R32 have no config, generated by sequential pairing of winners (indices 0-1 → 0, 2-3 → 1, …). - **Simulation never mutates JSON** — overlay stored in `localStorage` `wc2026_simulation`, keyed by bracket match ids (`R32-1`, `QF-2`, `FINAL`, …). ### Mock data design (2026-06-11, step 1) - **Real country names, fictional results** — generated deterministically (seed 2026), script deleted after run; data is now static JSON. - **State crafted to test both bracket modes:** matchdays 1-2 all `finished`; matchday 3 `finished` for groups A–F, `scheduled` for G–L (match 61, Group G, is `live`). So R32 slots fed by A–F resolve to real teams; G–L and all `third` slots show placeholders. - **Knockout results:** `R32-2` (match 74) finished 1-1 + penalties 4-3; `R32-4` (match 76) finished 2-0. Everything else `scheduled` with `null` scores — `results.json` has an entry for **all 104 matches**. - **Image paths:** `flag`/`image` JSON values are relative to `assets/images/` (e.g. `flags/mex.svg`, `stadiums/azteca.svg`). - **Opener** (match 1) is MEX at Estadio Azteca 2026-06-11; **final** (match 104) at MetLife 2026-07-19. ### Base layout decisions (2026-06-11, step 2) - **Hero priority: live > next scheduled** — a live match replaces the countdown with the score + pulse badge. Mock data keeps match 61 permanently `live`, so the countdown only shows if that status is changed (verified working by temporarily setting it to `scheduled`). - **i18n mechanics:** static HTML uses `data-i18n` / `data-i18n-aria` attributes re-applied by `applyI18n()`; dynamic renders call `t()` and listen for the `langchange` event on `document`. Phases translate via `translatePhase()` (PT: R32 = "16 avos de final"). - **Tab routing:** hash (`#matches`) + `wc2026_prefs.lastTab`, `history.replaceState` to avoid history spam; precedence on load: hash → lastTab → home. - **Default language:** `navigator.language` startsWith `pt` → PT, else EN; only persisted when the user clicks the toggle. - **Preview server:** `.claude/launch.json` at `R:\lucas-kalil\Projects\` defines `worldcup2026` (python http.server, port 8126) for the Claude Preview panel. ### Schedule + performance decisions (2026-06-11, step 3) - **`schedule.js` ⇄ `app.js` circular import is intentional** — `app.js` calls `initSchedule()`, `schedule.js` imports `getData`/`formatMatchTime`/`flagSrc` back. Safe in native ESM because all calls happen after both modules evaluate; keep this pattern for `groups.js`/`bracket.js`/`modal.js`. - **Filter UX:** toolbar is rebuilt only on init/langchange/clear (state restored programmatically); list re-renders on every filter change. Filter state is in-memory only (not persisted) by design. - **Knockout cards show "TBD"** until step 7 swaps `teamColumnHTML`'s lookup to `resolveBracketTeams()`. - **Perf: no `backdrop-filter` on repeated cards** — `.match-card` overrides `.glass` blur (huge paint cost × 104 cards, invisible over the smooth gradient). Same rule applies to any future card grid (stadiums, bracket). - **Perf: fixed gradient lives on `body::before` (position: fixed)**, not `background-attachment: fixed` (the latter repaints the whole background on scroll). ### Standings decisions (2026-06-11, step 4) - **Only `status: "finished"` matches count toward standings** — live scores are ignored until full-time (keeps standings stable and bracket resolution deterministic). - **Tiebreak order:** points → goal difference → goals for → team id alphabetical (stable fallback). Verified against an independent Python computation for groups A and G. - **`computeStandings()` / `isGroupFinished()` exported from `groups.js`** — bracket.js (step 7) must import these instead of recomputing. ### Stadiums decisions (2026-06-11, step 5) - **`stadiums.js` module added** — spec §4 has no module for the stadiums view; a dedicated view module keeps the per-view pattern (schedule/groups/bracket) instead of growing `app.js`. - **Cross-link:** "View matches" on a stadium card calls `setStadiumFilter(name)` (exported by `schedule.js`, resets other filters) + `navigateTo('matches')` (exported by `app.js`). ### Modal decisions (2026-06-11, step 6) - **Native `` + `showModal()`** — focus trap, Esc-to-close and `::backdrop` come free; no custom trap code. Backdrop click detected via `event.target === dialog` (content is in a padded inner div). - **Focus restore:** opener element saved in `openMatchModal()` and re-focused on `close`. - **Card → modal wiring is event delegation on `#schedule-root`** (click + Enter/Space keydown), so it survives list re-renders. Cards got `tabindex="0" role="button" aria-label` already in this step. - **`openMatchModal(matchId)` is the public API** — bracket (step 7) and any future view should call it rather than building their own. ### Bracket decisions (2026-06-11, step 7) - **Tree model is language-neutral**: slots are `{ teamId }` or `{ ph: { kind, … } }`; placeholder text is produced at render time by `slotDisplay()` so language switches never invalidate the tree. - **Tree is cached** in `bracket.js`; `invalidateBracket()` exists for the simulation overlay (step 9). Results are static per page load, so nothing else invalidates it. - **`resolveBracketTeams(matchOrRef)`** accepts a match object (group or knockout) or a bracketRef string and always returns `{ home, away }` as `{ team: Team|null, label: string }` — schedule cards, modal, and search/team filters all consume this, so knockout matches become searchable/filterable once resolved. - **Connector geometry**: all bracket columns share equal height with `flex: 1` slots, so pair children sit at 25%/75% and the next round's node at 50% — pure-CSS connectors (`::before`/`::after` stubs + pair vertical) meet exactly. Column gap 44px = 22px out-stub + 22px in-stub. Breaking the equal-height invariant breaks the lines. - **Final column** holds champion box (top), FINAL (middle, aligns with SF pair), third-place block (bottom); its champion/third slots suppress the incoming connector stub. ### Bracket interaction decisions (2026-06-11, step 8) - **"Full path" on hover/focus** = hovered node + its entire feeder subtree + its winner's route to the FINAL; THIRD-PLACE lights both SFs. Non-path nodes dim via `.has-path` on the canvas. Computed from ref arithmetic (`floor(i/2)` up, `2i/2i+1` down), no tree lookup. - **Zoom = CSS `transform: scale()` on the canvas + a `#bracket-zoom` box sized to `natural × scale`** (transform doesn't affect layout, so the box gives the scroll container the right scrollable area). Pointer-anchored: scroll adjusted so the point under the cursor stays put. Clamped 0.4–2. - **Natural canvas size is measured lazily** (`ensureMeasured()`) because the bracket panel can be `hidden` at render time (offsetWidth 0). - **Pan + pinch via Pointer Events** with `touch-action: none` on the wrap (we own all gestures there; page scroll over the bracket is intentionally captured, like a map widget). - **Drag–click conflict:** >5px movement sets a flag; a capture-phase click listener on the wrap swallows the click that ends a drag. The flag resets on the next `pointerdown`, so synthetic `el.click()` without pointerdown can be falsely suppressed in tests — dispatch pointerdown/up first. - **Zoom level survives langchange re-renders** (module-level `view.scale`) but intentionally not reloads (not in prefs). ### Simulation decisions (2026-06-11, step 9) - **Separation of concerns in the tree:** `decide()` applies only real finished results; `applySimulation()` overlays user picks afterwards and never overrides a real result. Stale entries (winner no longer among the resolved teams) are silently ignored — same validation the prediction import (step 12) will use. - **Sim UX:** "Simulation" toggle in the bracket toolbar; eligible nodes (both teams resolved, real result still `scheduled`) get dashed blue borders; clicking one opens a small native `` picker. An unequal score auto-selects the winner; a draw requires an explicit pick (penalties implied). Empty score defaults to 1-0 for the picked winner. - **Storage format** is exactly the complement-spec shape: `wc2026_simulation = { "R32-6": { winner: "FRA", score: "2-1" } }` keyed by bracketRef, score oriented home-away. - **`simchange` custom event** fires after any pick/reset; `schedule.js` listens and re-renders so simulated teams appear on knockout cards too (intentional leak — resolved tree is the single source). - **Simulated nodes** show a blue "SIM" corner chip and blue scores; the modal shows none of this (it reads real results only). ### Responsive/a11y decisions (2026-06-11, step 10) - **Breakpoints:** ≤767 (tight spacing, bracket `--node-w: 168px`/gap 36px — connector stub offsets must stay at gap/2, overridden in the same media query), 768–1439 (single-row header, centered menu), 1440+ (container widens to 1360px). - **Tabs follow the WAI-ARIA pattern:** roving tabindex + ArrowLeft/Right/Home/End in `initTabs()`; focus follows activation. - **Dialogs get `aria-label`** set at open time (match name + phase); schedule count is `aria-live="polite"`; countdown has `role="timer"` + label. - **Entry animations:** every unhidden `.panel` fades in; card grids (`.match-grid/.groups-grid/.stadiums-grid > *`) slide up with a 45ms stagger on the first 6 children, 260ms for the rest. All killed by `prefers-reduced-motion`. ### Extra features decisions (2026-06-12, step 12 — done before step 11 at user request) - **Favorites:** single global capture-phase click delegation in `app.js` handles every `.fav-btn` (schedule, groups, modal) and dispatches `favchange`; each view re-renders itself. Stars never trigger the card/modal click (guard via `closest('.fav-btn')`). Bracket shows highlight only (no stars — nodes too small). Favorite involvement = gold left border. - **`getFavoriteMatches(matches, favorites)`** lives in `bracket.js` (needs `resolveBracketTeams`), imported by `schedule.js` for the "My matches" filter. - **Time mode:** header `#time-toggle` flips `wc2026_prefs.timeMode` and dispatches `timemodechange`; `formatMatchTime()` already defaulted to the pref, so views just re-render. - **Challenge:** sim entries for real-finished matches can't be created in the UI (locked) but old ones persist in storage — that's by design: predictions are made while matches are `scheduled`, then scored when results land. Card renders only when ≥1 finished knockout match exists. - **Share/import:** `?prediction=` is stripped from the URL via `history.replaceState` whether applied or not (prevents re-prompt loops). Declining `confirm()` keeps local picks; unknown refs are rejected wholesale. - **`.ics`:** RFC 5545 TEXT escaping (`\,` etc.) applied even though the spec template shows raw commas — RFC compliance wins; verified output imports with CRLF-only endings. - **Custom events now in play:** `langchange`, `simchange`, `favchange`, `timemodechange` — all on `document`; views own their re-renders. ### Build complete (2026-06-12, step 11 — all 12 steps done) - README is the user-facing manual (run, deploy, JSON maintenance, localStorage keys, acceptance checklist). Keep it in sync when data formats change. - **Verified at completion:** spec §18 criteria all pass; JS = 74 KB total (budget 300 KB); no root-absolute paths (GitHub Pages safe). **Not yet verified:** Lighthouse > 90 (needs a deployed URL or local Lighthouse run); actual GitHub Pages deploy. - No commits made yet — repo initialized but empty; commit when the user asks. ### Workflow - **12-step build plan with approval gates** — user approves each step before the next starts; summary after each step. Plan: `C:\Users\Lucas\.claude\plans\read-r-lucas-kalil-projects-web-worldcup-goofy-meerkat.md`. - **Git repo, commits only when the user asks.** --- ## Known gotchas ### 1. `fetch()` of JSON fails on `file://` **Where:** any local testing of `index.html` **Why:** browsers block `fetch` of local files (CORS/origin rules) **Symptom if forgotten:** blank app, console CORS errors, wasted debugging **Solution:** always serve via `python -m http.server` (or any static server) from the project root ### 2. `.ics` requires CRLF line endings **Where:** `assets/js/calendar.js` **Why:** RFC 5545 mandates `\r\n` between lines; some calendar apps reject `\n` **Symptom if forgotten:** exported event silently fails to import in Outlook/Apple Calendar **Solution:** join VCALENDAR lines with `\r\n` explicitly ### 3. Third-place slots are `null` until defined **Where:** `data/bracket-config.json` → `thirdPlaceAssignment` **Why:** the 8 best third-place teams are only known after the group stage **Symptom if forgotten:** crash or "undefined" team names in R32 rendering **Solution:** `resolveBracketTeams()` must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever a slot is `null` or the group isn't finished ### 4. Claude Preview screenshots can hang (tooling, not app) **Where:** Claude Preview panel during verification **Why:** the preview window's screenshot pipeline occasionally gets stuck; `preview_eval` keeps working **Symptom if forgotten:** wasted debugging hunting a nonexistent app freeze **Solution:** `preview_stop` + `preview_start` recovers it; verify state via `preview_eval` first before suspecting the app ### 5. Stale JS modules in the dev browser **Where:** any JS edit while previewing via `python -m http.server` **Why:** the server sends no cache headers, so browsers heuristically cache ES modules; a normal reload can keep serving old code **Symptom if forgotten:** "module does not provide an export" errors or old behavior despite correct code on disk **Solution:** `Promise.all(files.map(f => fetch(f, { cache: 'reload' })))` then `location.reload()`, or DevTools hard reload ### 6. `setPointerCapture` on pointerdown kills element clicks **Where:** `assets/js/bracket.js` drag/pan handling on `#bracket-wrap` **Why:** capturing a pointer retargets the eventual `click` event to the capture element, so delegation via `event.target.closest('.bracket-match')` never matches — modal and simulation clicks silently die **Symptom if forgotten:** bracket nodes unclickable with real input while synthetic `el.click()` tests still pass **Solution:** capture only after the drag threshold (>5px) is exceeded, inside `pointermove`, wrapped in try/catch. **Always verify click flows with `preview_click` (trusted input), not `element.click()`.** ### 7. GitHub Pages serves under a subpath **Where:** all asset/data URLs in `index.html` and JS `fetch` calls **Why:** project pages live at `https://.github.io//`, so root-absolute paths (`/data/...`) break **Symptom if forgotten:** works locally, 404s on GitHub Pages **Solution:** use relative paths (`data/matches.json`, `assets/...`) everywhere ### 8. Claude Preview: resize beyond the native window breaks clicks/screenshots **Where:** Claude Preview panel, `preview_resize` wider than the native window (~791 CSS px) **Why:** viewport emulation desyncs the capture/click surface from the page (screenshots show the page squeezed in the left half; `preview_click` lands on wrong coordinates and silently does nothing) **Symptom if forgotten:** "clicks don't work" / half-black screenshots at desktop widths, wasted debugging — the app itself is fine (`preview_eval` geometry confirms) **Solution:** at emulated widths > native, navigate via `preview_eval` + exported `navigateTo()` and verify geometry via eval/inspect; trust screenshots only at widths ≤ native. `preview_resize preset: desktop` resets to native and fixes clicks. --- ## Patterns for future changes ### How to update real-world data (scores, schedule) Follow `how-refresh-data.md` (project root). In short: 1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare). 2. Once group stage ends: fill `data/bracket-config.json` → `thirdPlaceAssignment` (slot → group letter). Nothing else changes. ### Real-data migration (2026-06-12) - `how-update.md` (project root) is the full runbook for replacing mock `data/*.json` with real World Cup 2026 data: file-by-file schemas, order of operations (stadiums → teams → groups → bracket-config.round32 → matches → results → thirdPlaceAssignment), and a cross-file integrity checklist (group membership, id ranges, bracketRef uniqueness, stadium name/city matches). - Flags one open decision: `stadiums.json` has 30 entries (original bid shortlist) vs. the 16 venues actually used by the real tournament — confirm with user whether to trim before/while editing `matches.json`. ### Real-data migration DONE (2026-06-12) - **All 6 `data/*.json` files now hold real WC2026 data.** Sources: Wikipedia per-group + knockout-stage articles (primary; local kickoff times with explicit UTC offsets), cross-checked vs ESPN/NBC/FOX/olympics.com/lumenfield.com. Full smoke test + desktop/mobile layout pass, console clean. - **Stadiums trimmed 30 → 16** (user decision). Cities use FIFA host-city names ("New York/New Jersey", "San Francisco Bay Area", "Boston"), not suburb names (East Rutherford, Santa Clara, Foxborough) — `matches.json` and `stadiums.json` must keep matching exactly. - **Match ids:** group matches 1–72 are *chronological by UTC kickoff* (≠ 6-per-group blocks); knockout ids 73–104 are FIFA's official match numbers. - **bracket-config app-order ↔ FIFA mapping:** R32-1..16 = FIFA matches 74, 77, 73, 75, 83, 84, 81, 82, 76, 78, 79, 80, 86, 88, 85, 87 (ordering chosen so the app's sequential pairing reproduces the official R16/QF/SF progression). - **Third-place slots → allowed groups** (for filling `thirdPlaceAssignment` after the group stage, per official draw): slot 1 (M74) A/B/C/D/F · slot 2 (M77) C/D/F/G/H · slot 3 (M81) B/E/F/I/J · slot 4 (M82) A/E/H/I/J · slot 5 (M79) C/E/F/H/I · slot 6 (M80) E/H/I/J/K · slot 7 (M85) E/F/G/I/J · slot 8 (M87) D/E/I/J/L. Each group letter may appear in only one slot. - **Results as of 2026-06-12:** ids 1–3 finished (MEX 2–0 RSA, KOR 2–1 CZE, CAN 1–1 BIH); USA–PAR (id 4) kicked off 01:00 UTC Jun 13 — first thing to update next session. ### Daily refresh (2026-06-13) - **Results updated through match id 4**: USA 4–1 PAR (Group D), confirmed via FIFA match centre + Yahoo/ESPN. Group D standings now show USA 1st (+3 GD), PAR 4th (-3 GD) — verified in Groups view. - ids 5 (QAT–SUI, Jun 13 19:00 UTC) and 6 (BRA–MAR, Jun 13 22:00 UTC) not yet played as of this update — still `scheduled`. Next session: check these plus continue daily routine per `how-refresh-data.md`. - **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. - **New-team flags** created in the house placeholder style: RSA, CZE, BIH, HAI, CUW, SWE, CPV, NOR, IRQ, COD; 24 unreferenced mock SVGs deleted (10 flags + 14 stadiums). Exactly 48 flags + 16 stadium images remain. - **Tiebreak note:** with all tiebreakers equal the app falls back to team-id alphabetical (e.g. BIH above CAN on 1 pt) — may differ from FIFA's published order (fair-play points / drawing of lots), acceptable by design. - Mock-data sections above (“Mock data design”, hero live-match note for match 61) are now historical. ### Daily refresh runbook (2026-06-12) - **`how-refresh-data.md` (project root) is the runbook for all updates during the tournament** — read it before touching any `data/*.json` from now on. It defines: daily `results.json` routine (scores/status, two-source rule, penalties only on ids 73–104), the one-time `thirdPlaceAssignment` fill (~Jun 27–28, slot → allowed-groups table), and the frozen files (stadiums/teams/groups/round32/assets/code — never edit). - `how-update.md` stays as the schema reference for the (completed) mock → real migration; `how-refresh-data.md` supersedes it for day-to-day work. ### 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.