19 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
Patterns for future changes
How to update real-world data (scores, schedule)
- Edit
data/results.json(scores/status) ordata/matches.json(schedule). - 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.
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.