50 KiB
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
- Spec compliance — complement spec wins on conflict.
- Visual quality — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
- Interactive bracket — hover path highlight, zoom, drag, simulation; the centerpiece feature.
- Easy maintenance — real data drop-in via JSON;
bracket-config.jsonis the only structural file edited after the group stage. - 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 inwc2026_prefs.lang. Static HTML usesdata-i18n/data-i18n-ariare-applied byapplyI18n(); dynamic renders callt()and listen forlangchange. Phases viatranslatePhase()(PT: R32 = "16 avos de final"). Default language:navigator.languagestartsWithpt→ PT, else EN; only persisted on toggle. storage.jsis the only access path tolocalStorage(wc2026_*keys, auto JSON). Holds prefs (lang,lastTab,timeMode),wc2026_favorites,wc2026_simulation.- Per-view modules (
schedule/groups/bracket/stadiums/stats) +app.jsentry. Circular importsapp.js⇄ view modules are intentional and safe in native ESM — all cross-calls happen at render runtime, after every module has evaluated.stats.jsimportsgetBracketTree,getFavorites,openMatchModalthis way too. - Custom events on
documentdrive 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 byformatMatchTime(match, stadium, mode)viaIntl.DateTimeFormat(mode="local"browser tz, or"stadium"timezone)..icsexport 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) andisGroupFinished()are exported and reused bybracket.js/stats.js(no recompute).- Best third-placed teams table (2026-06-28).
computeThirdPlaceRanking()(exported) takes each group's 3rd row (standings[letter][2]), ranks the 12 across groups by the same key (Pts → GD → GF → id) and flags the top 8qualified. Rendered as a full-width section below the 12 group cards in the Grupos tab, gated onallGroupsFinished()(meaningless mid-stage → omitted from the DOM). Reuses.standings-tablestyling, header tooltips and the favorite-row highlight; gold.row-third- ✓ for the 8 that advance, muted
.row-out+ — for 9–12, a dashed.cutline between 8 and 9. It only ranks the thirds for display — the slot→group allocation still lives inbracket-config.json(FIFA combination table), never derived from this ranking.
- ✓ for the 8 that advance, muted
Bracket (bracket.js)
- Tree 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;
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 withRETIRED 2026-07-03 by the wallchart redesign — see "Bracket redesign (2026-07-03)" below; connectors are now SVG paths generated from the same JS geometry as the cards, so no CSS invariant exists anymore.flex:1slots, 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 (sosimulated:false⇒ real). Stale entries (winner no longer resolved) are silently ignored. Eligible nodes (both teams resolved, real result stillscheduled) get dashed blue borders + a SIM chip. - Interactions: full-path highlight computed from ref arithmetic (
floor(i/2)up,2i/2i+1down), no tree lookup. Zoom = CSStransform:scale()on the canvas + a sized#bracket-zoombox, pointer-anchored, clamped 0.4–2; natural size measured lazily (ensureMeasured()— panel may behiddenat render). Pan/pinch via Pointer Events,touch-action:noneon the wrap. Drag–click conflict: capture pointer only after the >5px threshold (gotcha #6). - Share/import:
?prediction=base64(simulation)viagetShareableLink()/loadPredictionFromURL(); stripped from the URL (history.replaceState) whether applied or not; unknown refs rejected wholesale. Challenge card scores sim vs real finished knockout results.
Bracket redesign — center-out wallchart (2026-07-03, Step 1 of 4)
Full redesign spec settled via /grill-me: two switchable chart layouts (center-out wallchart = default, radial = Step 3) + a mobile round pager default ≤767px (Step 2), stadium-night art direction, lean escalating cards, fit-whole-chart initial framing, gold-real/blue-sim champion celebration (Step 4), built directly on master (deploy still gated on user-approved push). Step 1 (shipped): wallchart replaces the old left-to-right columns.
computeWallchartLayout()inbracket.jsis the single geometry source: absolute px positions for every card/title/champion box (GEOconstants; R32 1–8 left half, 9–16 right, later nodes at feeder midpoints) and the SVG bezier connectors + champion stem derived from the same numbers — cards and lines cannot drift apart. Tree/sim/share/challenge logic untouched; DOM contract preserved (data-ref,data-match-id, delegation, keyboard activation).- Fit-to-chart zoom: geometry is computed (never DOM-measured); a
ResizeObserveron#bracket-wrapcomputesview.fitwhen the panel becomes visible (hidden panel = clientWidth 0) and re-fits on resize unless the user zoomed. Zoom label "100%" = fit, reset returns to fit, clamp = [fit, 2]. The wrap carries an inlineaspect-ratio: W/Hfrom the engine so the fit view is never letterboxed (cappedmax-height: min(80vh, 840px)). - Cards: lean two-row + microline (
.bk-meta: kickoff viakickoffShort()honoring the Local/Stadium toggle — bracket now listens totimemodechange; LIVE pulse; FT =t('bracket.ft'), new i18n key EN/PT). Tier classesbk-r32…bk-finalescalate size/heat; the Final hero card also shows venue (.bk-venue). Champion box:has-championgold;is-sim= blue + SIM chip (a simulated champion must never read as real — same rule as the stats verdict; previously the old champion box showed sim champions in gold). - Path highlight now also lights SVG connectors: a path turns gold when both its
data-from/data-toendpoints are inpathRefs(); the champion stem carries FINAL→FINAL. - Motion: cards rise + connectors draw (
pathLength="1"+ dash animation), staggered by round, all inside@media (prefers-reduced-motion: no-preference); replays on each tab open (display:none restarts CSS animations) — intentional "chart assembles" effect. Interim states until later steps: mobile ≤767 shows the pinch-zoom wallchart (pager = Step 2); no view toggle yet (registry lands with the second view).Superseded same day — Steps 2+3 below.
Bracket redesign — Steps 2+3: view toggle, rounds pager, radial "orbit" (2026-07-03)
- View toggle: segmented
Fases | Chaveamento | Radialin the toolbar; explicit choice persists inwc2026_prefs.bracketView; with no stored pref the default follows the breakpoint (≤767px → rounds pager, else wallchart; amatchMedia('change')listener re-renders while unset). Zoom controls render only for chart views.render()dispatches: pager /chartHTML→wallchartInnerHTML|radialInnerHTML. Switching chart layouts resets to a fresh fit (view.layoutIdcheck ininitInteractions). - Rounds pager — button navigation ONLY (user decision): the first build used a scroll-snap
swipe track; the user rejected horizontal scrolling, so pages are plain sections toggled with
hiddenby the chips (initPager~15 lines, no ResizeObserver/height-clamp needed). Grid is max 2 columns (≥700px) — 3–4 columns made cards too narrow (user feedback). Opens on the first round with an unfinished match (firstOpenPage);pagerIndexsurvives re-renders. Cards (.bk-pcard) carry venue·city + status row and reuseteamRowHTML+ the samedata-ref/data-match-iddelegation (modal + sim editor work unchanged). - Radial = "orbit" view, redesigned to a user-supplied reference image (circular predictions
bracket with the trophy at center). NOT rectangular cards: circular flag tokens on concentric
rings — outer ring = 32 entrants, each ring inward = a round's winner slots (a match's winner
slot doubles as the next match's participant), trophy centerpiece (= the FINAL's winner slot,
champion flag + name when decided, sim-blue when simulated), third-place = small labeled pair
below the circle.
TGEOradii chosen so adjacent/consecutive rings never collide (validated with an automated pairwise-overlap eval in preview — keep doing that after any radius change). Semantics: elbow route lines (radial segment + bend dot) turn gold = real advancement, dashed blue = simulated pick; eliminated entrants grey out (tk-out), TBD slots are striped discs (tk-tbd); names/scores live in the shared app tooltip (has-tip/data-tip, delegation in app.jsinitTooltips) and the modal. Sim affordance lives on the winner slot (opts.slot, notopts.winner— a TBD slot is exactly the simulatable one; that inversion was a shipped-then- fixed bug). Path highlight generalized:showPathlights ANY[data-ref]element +.bk-leendpoints; hover/focus delegation usesclosest('[data-ref]'). - Toolbar gotcha:
.bracket-tools-leftholds 6 controls — needsflex-wrap: wrapor it forces ~500px of page overflow at 375px (found via body scrollWidth sweep on mobile).
Modal (modal.js)
- Native
<dialog>+showModal()→ focus trap, Esc,::backdropcome 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
statsfield per game inresults.json({ possession, shots, cards }, home/away followinghomeTeam/awayTeam; possession %, total shots, yellow cards). Renders real stats when present, else the—placeholder +modal.statsSoonnote. Adding stats to more games = editresults.jsononly.
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):overifstatus==='finished'ORnow ≥ kickoff + window;liveifstatus==='live'ORnow ≥ kickoff; elseupcoming. JSON always wins (finished/live force); the clock only advances when JSON lags. Window:GROUP_WINDOW_MS = 2hforGroup*, elseKO_WINDOW_MS = 3h. findFeaturedMatches(now)picks the earliest non-overmatch 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 1sheroTick; signature"id:state"(joined for the set) → fullrenderHero()on change, else justupdateCountdown().renderHerois idempotent and re-arms the timer (if (heroTimer) return).- Hero resolves teams via
resolveBracketTeams(match)(not rawmatch.homeTeam), so knockout featured matches show real teams/flags once resolved and a placeholder label otherwise — same path as schedule cards/modal.heroTeamHTML(slot)takes a{team,label}slot. Bug fixed 2026-06-28: the hero previously readmatch.homeTeam/awayTeamdirectly; harmless during the group stage (those fields exist) but the moment the next match became an R32 game (ids 73+, which carry onlybracketRef) the home hero showed "A definir vs A definir". Watch for this class of bug anywhere that readsmatch.homeTeamraw instead of resolving. - Live score shown only if
result.homeScore/awayScoreare non-null; no elapsed-time clock (would be inaccurate on a static site). Badge "Bola rolando!" = keyhero.inProgress(renamed fromhero.kickoff);hero.livestill used by schedule/modal. Scope: hero only — Matches/Modal/ Bracket live badges stay JSON-status-driven (small transient inconsistency accepted). When the Final goesover, 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 ofinit(), after views register listeners) arms onesetInterval(if (pollTimer) return).pollResults()fetchesdata/results.json?t=${Date.now()}withcache:'no-store'. (As of 2026-06-18 the initialloadData()fetch also uses?t=Date.now(); the old hand-bumped?v=DATA_VERSIONcache-buster was removed — see Cache-busting runbook.) - Signature = full response text (catches score corrections,
statsbackfill, penalties — a finished-count signature would miss them). On change: rewritedata.resultsand rebuilddata.resultByMatchId(the derived map),invalidateBracket(), dispatchdatachange. - 3 reinforcements over plain fixed poll: (1) Page Visibility — interval no-ops when
document.hidden;visibilitychangedoes an immediate fetch on return. (2) Stop at the end —tournamentOver()checksFINAL's JSONstatus==='finished'(not clock-over, which would stop 3h after kickoff before the score lands) →stopResultsPolling(). (3) Content signature (above). bracket-config.jsonpiggybacks the change event: the poll fetches onlyresults.jsoneach tick, but on a detected change it also refetchesbracket-config.jsonthe 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 newthirdPlaceAssignmentwithout F5.- Fan-out: every view has a
datachangelistener (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-filteron repeated cards —.match-cardoverrides.glassblur (huge paint × 104 cards). Same rule for any future card grid. - Fixed gradient lives on
body::before(position:fixed), notbackground-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+ (.containerwidens to 1360px). - WAI-ARIA tabs: roving tabindex + Arrow/Home/End in
initTabs(), focus follows activation. Dialogs getaria-labelat open; schedule countaria-live="polite"; countdownrole="timer". Entry animations (panel fade, card stagger) all gated byprefers-reduced-motion.
Gotchas
fetch()of JSON fails onfile://— always serve viapython -m http.server(Claude Previewworldcup2026, port 8126). Symptom: blank app + CORS errors.- GitHub Pages / Hostinger serve under a subpath — use relative paths everywhere
(
data/matches.json,assets/...); root-absolute (/data/...) 404s in production. .icsrequires CRLF line endings (calendar.js) — RFC 5545 mandates\r\n; some calendar apps silently reject\n.- Third-place slots are
nulluntil filled (bracket-config.json.thirdPlaceAssignment) —resolveBracketTeams()must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever a slot isnullor its group isn't finished. Symptom if forgotten: crash / "undefined" in R32. - Stale JS modules in the dev browser —
python -m http.serversends no cache headers, so browsers heuristically cache ES modules. Hard-reload viaPromise.all(files.map(f => fetch(f, {cache:'reload'}))) → location.reload(), or DevTools hard reload. setPointerCaptureon pointerdown kills element clicks (bracket.js) — capturing retargets the eventualclick, so delegation never matches → modal/sim clicks die. Capture only after the5px drag threshold, in
pointermove, try/catch. Verify click flows withpreview_click(trusted input), notelement.click().- Claude Preview screenshots can hang (tooling, not app) —
preview_evalkeeps working;preview_stop+preview_startrecovers. Verify state viapreview_evalbefore suspecting the app. - 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: desktopresets it. aspect-ratio+min-heightcan transfer size INTO width (bracket wallchart, 2026-07-03) — on a box withwidth: auto, a violatedmin-heighttransfers back through the ratio and widens the element past its container (on mobile the 220px min became 585px of page overflow). Fix: give the box a definite width (width: 100%); then the ratio only drives height and min/max-height clamp it without transfer.
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 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.?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.
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):
- Data commit (
results.json, +bracket-config.jsonon 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).
- 1 game →
- 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: everypushtomaster(orworkflow_dispatch) deploys viaSamKirkland/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(branchmaster). 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, soserver-diris relative to it — do not prefixpublic_html/(causespublic_html/public_html/...). Final path:public_html/worldcup2026/. If FTPS is rejected, switchprotocoltoftp. excluderemoves.git*,.github/,.agents/,docs/,README.md,DEVELOPMENT.md,how-*.md,*-en.mdspecs — onlyindex.html+assets/+data/reach the site. Newdata//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 vialoadOptional(name, fallback)→ absent/404 returns the empty default silently, warns only on a present-but-malformed file. Core + optional fetch concurrently. - Section-gating —
SECTIONSregistry instats.js: each section{ id, navKey, available(model), body(model) }renders (and shows its chip) only whenavailableholds; else it is omitted from the DOM entirely and the nav never points at emptiness. - Sticky scrollspy sub-nav: hero +
<nav.stats-subnav>(anchor chips) + one section per available section + footer. Chips are<a href="#stats-{id}">butpreventDefault+scrollIntoView— they NEVER setlocation.hash(the tab router listens onhashchange; a real#stats-teamswould route to an unknown tab → bounce to Home). Scrollspy is position-based (rAF-throttledscrollreadinggetBoundingClientRect) + an explicit "at page bottom → last section" rule (an IntersectionObserver band left a short final section unlit). --header-hCSS var kept live bytrackHeaderHeight()(ResizeObserveron the variable-height sticky header). Sub-nav sticks attop: var(--header-h); sections usescroll-margin-top.- Media fallback (§0.3):
flagImg(team,w,h)emits the flag withdata-monogram="<id>"; a one-time capture-phaseerrorlistener replaces a broken flag with a 3-letter<span.flag-fallback>— 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()readsgetBracketTree().nodesByRef.get('FINAL')and returnsnullunlessstatus==='finished' && !simulated && winner(a user's simulated champion never leaks). Falls back to the aggregate "in progress" hero until the final is really finished.
- 2/3/4 podium, shared count-up tiles) is gated on the REAL final:
- Goals-by-round chart (Overview): group stage split into 3 matchdays (
computeGroupMatchdays: sort each group's 6 fixtures by kickoff, chunk into pairs —matches.jsonhas 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 && finishedgate 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 toa.rank - b.rank.- Favorite-team row highlight (gold):
row-favwhengetFavorites()includes the team;favchangere-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.jsimportsgetFavorites(storage.js) +openMatchModal(modal.js).
Stage D — Records section + format-48 debuts
recordssection 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. (biggestWinmoved 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 frommodel.verdict). Counts come fromgetData()/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
comparatorsection,available:(m)=>m.finishedCount > 0. Two<select>s (alphabetical, 48 teams) default to the top-2 ranked; choice survives langchange (module-levelcmpA/cmpB). On change, only the bars panel re-renders. Diverging mirrored bars scaled tomax(a,b,1); higher side's number gold. MetricsCMP_METRICSare all non-negative (P, W, GF, GA, CS, Pts — GD excluded, it can be negative).cmp-growscaleX animation, off underprefers-reduced-motion.- Players side deferred to Stage H (graceful degradation, not the plan's literal Teams/Players toggle — a disabled toggle would be a visible dead control). Teams comparator only for now.
Stage J round 1 — release polish + merge to master (2026-06-17)
Polish over A–F: i18n audit (no hardcoded strings), a11y (sections aria-labelled + tabindex=-1,
table caption + sort buttons + aria-sort, sub-nav is a <nav>), reduced-motion gating, cross-tab
regression — no code fixes were needed. README got a Stats bullet. Deferred to the actual
deploy: the Lighthouse run (the once-deferred final DATA_VERSION bump is moot — DATA_VERSION
was removed 2026-06-18). Merge sequence: merge latest
master→branch (resolve conflicts on the branch, never on master), re-verify, then master ← branch --no-ff. Pushing to origin (which triggers the deploy) is the user's explicit final go.
Sub-nav polish — inner track + edge fades + spy-suppress (2026-06-17, on master)
- Edge fades mirror the header tabs: chips live in an inner
.stats-subnav-track(the scroll container); the fademask-imageis on the track, so the pill's background/rounded ends stay crisp..stats-subnavisoverflow:hidden;updateSubnavFades(nav)toggles.fade-left/-rightfrom the track's scroll metrics. All sub-nav scroll JS targets the track, not the nav. - Scrollspy "jump" on chip click fixed: a chip click sets
suppressSpyUntil = Date.now()+700(0 under reduced-motion) andupdateSpy()early-returns while suppressed, so the clicked chip owns the active state until the smooth scroll settles.
Leader cards — tied-team carousel (2026-06-19)
The Team-statistics "leader" cards (Best attack / Best defense / Most clean sheets) became a
config-driven set of 6 and each now rotates through ALL teams tied on its headline metric
(was: only the single top team). New cards: Most wins, Most goals conceded, Best goal
difference (GD value shows a + sign when positive).
- Tie grouping is by the headline metric ALONE (decided via /grill-me) —
gf/ga/cleanSheets/won/ga/gd— not the secondary tiebreakers, so e.g. all teams level on goals-for share one card. Within the group the existingcmp(with tiebreakers) sets order, so the first team shown is unchanged from before. Driven by theLEADER_CARDSarray instats.js;computeLeadersnow returns[{ id, labelKey, metric, group: Row[] }](was an object of single rows). - Carousel UX: auto-advance every
ROTATE_MS = 3500; pauses on hover/focus, disabled underprefers-reduced-motion(arrows still work). ◀▶ arrows are circular (wrap-around); a manual click effectively restarts the cadence (it resumes fresh on pointer/focus-leave). Indicator = dots (one per tied team, active = gold) up toDOTS_MAX = 8; above 8 the dots become an"i / n"counter (keeps the card compact — e.g. early-Cup Best defense routinely has 8 teams at GA 0). A 1-team group renders the plain static card, identical to before (no arrows/dots/timer). - Timer lifecycle (cf. gotcha #6):
setupLeaderCarousels(root)runs at the end ofrender(); intervals are tracked in module-levelleaderTimersand cleared at the top ofrender()(clearLeaderTimers()) so alangchange/datachangere-render never leaves a timer firing on detached DOM.favchangedoes not touch these cards, so their carousels survive it untouched. Only the flag+name swap on rotate — the big value is shared by the whole tied group, so it never changes. - i18n keys added (EN+PT):
stats.mostWins,stats.mostConceded,stats.bestGoalDiff,stats.leaderPrev,stats.leaderNext. CSS:.leader-stage/.leader-nav/.leader-dots/.leader-dot/ .leader-counterinstats.css.
Partial stats tab built during the Cup (foundation, 2026-06-14)
The 6th stats tab was first shipped incrementally as the evolving foundation of the post-Cup plan
(same tab/module; post-Cup sections "light up" later). Files: assets/js/stats.js +
assets/css/stats.css. Philosophy (decided via /grill-me): current-to-date aggregates, only
status==='finished' (consistent with computeStandings); "X of 104" is framing, not a gap.
aggregateTeams() is its own tournament-wide aggregation (group + knockout); optional per-game
stats enters with per-game gating. Memoized model (let model), re-render of labels on langchange.
Patterns & How-tos
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 UI text in HTML/JS. (Data values — team/stadium names, cities — come from JSON and are not translated.)
How to add a new localStorage preference
- Extend the
wc2026_prefsshape (document the new field here). - Read/write only via
storage.jsget/set.
Tooltips + mobile legend (2026-06-14)
- Table-header abbreviations (Stats team table + the 12 Groups tables) get a custom glass tooltip
(not native
title).initTooltips()inapp.js: a singleposition:fixed.app-tooltipvia event delegation ondocument(so it survives re-renders and is never clipped byoverflow-x:autocontainers); clamps to viewport, flips below if it doesn't fit above. - Give a header a tooltip: add
has-tip+data-tip="<text>"+aria-label="<abbr> — <text>"; texts ini18n.jsnamespacetip.*(EN/PT), reused by both tables. - Mobile legend:
<p class="stats-legend">(display:nonedesktop,flex≤600px) — covers touch where hover doesn't fire.legendHTML()instats.js/groups.js. CSS lives instats.css(loaded globally, so it also applies to Groups).
How to add a stadium SVG
Follow the trimmed structure of the 16 existing ones (chrome stripped 2026-06-14 — stadiums.js
renders name/city/capacity as HTML, so the SVG must not duplicate them): <svg viewBox="...">
(no width/height) → <defs><style> with only the
struct/thin/hair/concrete/stands/canopy/void/pitch/pline/acc/accs/green/ribs/louver classes +
frit pattern → a single <g> illustration cropped tightly (~10px padding). Aim for a viewBox aspect
ratio near 4:3 (~1.2–1.3) to match .stadium-img { aspect-ratio: 4/3; object-fit: cover } in
style.css (4:3, not 16:9 — the SVGs' natural ratios are ~1.07–1.32, and 16:9 cropped ~28% of
height, slicing the illustrations). The white tower shapes on some cards (class="void") are the
press-box/scoreboard — intentional, don't remove.
PWA — installable (Tier 1, 2026-06-16)
Scope shipped = Tier 1 (manifest + icons + meta tags) — meets every install criterion; no JS
changed. Files: manifest.json (root), favicon.ico (root), assets/icons/ (icon.svg master +
192/512 PNGs any + maskable + apple-touch 180 + favicon-16/32). index.html <head> got the PWA
block (manifest link, <meta theme-color #081421>, favicons, apple-mobile-web-app-* meta). Manifest:
name "World Cup 2026 Hub" / short_name "WC 2026 Hub", display:standalone, colors #081421
(--bg-primary), start_url:"." + scope:"./" relative (gotcha #2). Named manifest.json (not
.webmanifest) for safe MIME on Hostinger. To change the icon: edit the SVG(s) and re-run the
ImageMagick rasterize commands (magick -background none icon.svg -resize NxN ...; favicon.ico =
16+32). Tier 2 (service worker / offline) is deliberately deferred — see issues.md; if built it
must exclude data/*.json from the cache or it breaks the live-refresh poll.
Responsive header — 2 bands + scrollable tabs (2026-06-15)
Single-row flip (.tabs { flex:0 1 auto; margin-inline:auto }) moved from @media (min-width:768px)
→ @media (min-width:1100px) (single row needs ~950px of content; below that the controls
overflowed). Below 1100px: two stable bands (band 1 = logo + controls, band 2 = scrollable tabs).
Edge fades via mask-image toggled by updateTabFades(); active tab kept visible via
scrollActiveTabIntoView() (uses scrollLeft, not scrollIntoView, to avoid scrolling the
page). The time button collapses to a 🕐 icon at ≤420px (a11y intact via data-i18n-aria). This
supersedes the old "768–1439 single-row header" note.
Docs — README showcase + DEVELOPMENT.md split (2026-07-04)
The root README was reframed (via /grill-me) from a dev/maintenance guide into a non-technical
showcase (English): tagline, shields.io badges, prominent live-demo link
(https://lucaskalil.com/worldcup2026 — the public URL; not previously recorded anywhere),
per-page screenshot gallery (Home/Matches/Groups/Knockout/Stadiums/Stats), and a plain-language
"Under the hood" section. All the old technical content (run locally, project structure, JSON
maintenance, local storage, deploy, acceptance criteria, roadmap) moved to a new DEVELOPMENT.md;
the README's stale "mock data / GitHub Pages" framing was corrected to real-data + the real Hostinger
deploy. Screenshots live in docs/screenshots/*.png, captured with headless Edge
(msedge --headless=new --window-size=1366,H --virtual-time-budget=6000 --lang=en-US --screenshot,
--lang=en-US forces the EN UI; served from Claude Preview worldcup2026 on :8126) — repeat that to
refresh them. Deploy exclude updated to drop docs/ + DEVELOPMENT.md (docs never ship to the
live site — see Deploy runbook). No app-code/version change (APP_VERSION untouched; docs are
excluded from deploy).
How to record a decision (after finishing a unit of work)
- Tick the item in
.agents/TODO.md. - Append the new decision/gotcha/pattern to the right section here (don't rewrite existing entries; don't add dated refresh logs — those go in git + the Current State rolling window).
- Rewrite
project-map.mdif structure/functions changed.
Current State
Updated 2026-07-03. Bracket redesign Steps 1–3 shipped on master (not yet pushed): the
Knockout tab has 3 switchable views — center-out wallchart (desktop default), radial "orbit" (flag
tokens per the user's reference image), rounds pager (mobile default, button-only navigation, ≤2
columns). See Architecture → "Bracket redesign" (both entries). Pending: Step 4 champion
celebration + polish pass + version bump to v1.1.0.
Data: R32 underway — group stage COMPLETE (1–72) + R32 matches 73
(RSA 0–1 CAN), 74 (GER 1–1 PAR, PAR 4–3 pens), 75 (NED 1–1 MAR, MAR 3–2 pens), 76
(BRA 2–1 JPN), 77 (FRA 3–0 SWE), 78 (CIV 1–2 NOR), 79 (MEX 2–0 ECU), 80
(ENG 2–1 COD), 81 (USA 2–0 BIH) and 82 (BEL 3–2 SEN, AET) finished (82/104 total);
remaining R32 ids 83–88 are next.
thirdPlaceAssignment FILLED (8 best thirds → R32 — see the rolling refresh list below).
Cache-busting is now automatic (?t=Date.now(); DATA_VERSION removed 2026-06-18). APP_VERSION = v1.0.3
(bumped 2026-06-28: hero knockout-resolution fix + best-third ranking table in the Grupos tab). Build: all 12 steps + real-data migration
done; Stats stages A–D + F + J(r1) merged to master and live (E skipped). Stats Team-statistics
leader cards now rotate through tied teams + 3 new metric cards (Most wins / Most goals conceded /
Best goal difference) — see Stats Screen → "Leader cards — tied-team carousel".
Recent refreshes (rolling — keep the last 3, prune older; full detail in git)
- 2026-07-02 — R32 ids 81 & 82. Match 81 (R32-7, USA D1 × BIH 3rd-B): USA 2–0 BIH —
Balogun 45' (his 3rd of the tournament) then sent off 64' for a reckless tackle on Muharemović,
Tillman sealed it with an 82' direct free-kick; USA's first WC knockout win since 2002, playing the
final ~30min with 10 men — 2-source+ confirmed ESPN(gid 760494)/CBS/NBC Bay Area/NPR/CNN/FOX/US
Soccer/Opta Analyst. Stats: poss 48/52 (ESPN match box), shots 8/10 (Opta Analyst total-shots —
cross-checked against ESPN's own SOG-conversion %: 2/8=25% and 3/10=30%, both match exactly). Cards
1/1 (Balogun red 64' only; BIH's Radeljić yellow 80' — excluded a 2nd BIH yellow shown to head coach
Barbarez, a bench/technical-area card, not a player card). Regulation, no penalties. Winner
propagated: USA → R16-4 vs BEL (verified in bracket, matches real-world reporting). Match 82
(R32-8, BEL G1 × SEN 3rd-I): BEL 3–2 SEN (AET) — Diarra 25', Sarr 51' put Senegal 2-0 up; sub
Lukaku 86' and Tielemans 89' forced extra time, Tielemans converted a 125' penalty (VAR) for the
latest goal in WC history — 2-source+ confirmed ESPN(gid 760493)/Outlook India/Opta Analyst/CBS/
FOX/Sofascore. Stats: poss 52/48 (FotMob — ESPN's page didn't expose a possession stat, same gap as
the 77 FRA-SWE refresh), shots 18/5 (ESPN match box — initial reads were inconsistent/swapped, took
2 more fetches with explicit home/away attribution to settle; FotMob's conflicting 19/19 discarded
as the outlier). Cards 1/1 (Mechele BEL yellow 64', Camara SEN yellow 67'; excluded Belgium coach
Rudi Garcia's dissent yellow and an uncorroborated FotMob claim of a 2nd Camara yellow at 73' — no
source reports Senegal playing a man down). Decided in extra time by a live-play goal, not a
shootout — no
penaltiesfield. Winner propagated: BEL → R16-4 vs USA (verified in bracket). Next R32: ids 83–88. - 2026-07-01 (b) — R32 id 80 (R32-12, ENG L1 × COD 3rd-K): ENG 2–1 COD. Cipenga 7' gave Congo an early lead (their first-ever WC knockout appearance); Kane leveled 75' (header) and won it 86' (strike), both assists from Gordon — 2-source+ confirmed ESPN(gid 760495)/Olympics.com/ Fox Sports/englandfootball.com/CBS/NBC/Yahoo. Stats: poss 60/40, shots 9/5 (ESPN match box — a Sofascore-derived search snippet suggested 16/7 but that looked like a different attempt-counting method, so ESPN's box was used per the authoritative-source rule), cards 1/1 (Bellingham 19' ENG, Sadiki ~27' COD, both yellow, no reds — confirmed via Sofascore's card list). Regulation, no penalties. Winner propagated: ENG → R16-2 vs MEX (verified in bracket). Next R32: ids 81–88.
- 2026-07-01 — R32 ids 77 & 79. Match 77 (R32-2, FRA I1 × SWE F3): FRA 3–0 SWE — Mbappé 45', 74' (his 6th of the tournament, level with Messi's career WC tally), Barcola 53' — 2-source confirmed ESPN(gid 760492)/FIFA match centre/Al Jazeera/FOX/RTE. Stats: poss 61/39 (FotMob; ESPN's page didn't expose a possession stat), shots 11/6 (ESPN match box — used over FotMob's much higher 25/8 figure, which looked like a different attempt-counting method), cards 0/0 — checked ESPN boxscore + playbyplay, FOX, Al Jazeera live blog, RTE, Yahoo recap; none reported any booking, so treated as a clean game rather than an unconfirmed gap. Regulation, no penalties. Winner propagated: FRA → R16-1 vs PAR (verified in bracket). Match 79 (R32-11, MEX A1 × ECU E3): MEX 2–0 ECU — Quiñones 22', Jiménez 31'; Ecuador's Hincapié sent off 90+5' (covering his mouth during a dispute with Giménez, a new directive this tournament) — 2-source confirmed ESPN(gid 760491)/FIFA match centre/Al Jazeera/Yahoo/CBS. Stats: poss 43/57, shots 9/7, cards 0/3 (2 yellow + Hincapié's red) — all from the ESPN match box, red card cross-confirmed by the Yahoo live blog. Regulation, no penalties. Mexico (co-host) reached R16 for the first time in the format; winner propagated to R16-2 (verified in bracket). Next R32: ids 80–88.
Pending / next
- Knockout R32 (ids 73–88) — in progress. Done: 73 (RSA 0–1 CAN), 74 (GER 1–1 PAR, PAR 4–3 pens),
75 (NED 1–1 MAR, MAR 3–2 pens), 76 (BRA 2–1 JPN), 77 (FRA 3–0 SWE), 78 (CIV 1–2 NOR), 79 (MEX 2–0 ECU),
80 (ENG 2–1 COD), 81 (USA 2–0 BIH), 82 (BEL 3–2 SEN AET). Next: ids 83–88.
penaltiesapply on ids 73–104 (KO only — append"penalties": {home,away}and keephomeScore/awayScoreas the 90+30 score; id 82 was decided in extra time by a live goal, not a shootout, so it carries nopenaltiesfield despite going past 90+30). R16 ids 89–96 from 2026-07-04. Note: the 75 (NED×MAR) card count is single-source (Sofascore, only Diop 47') — re-confirm if a clean box surfaces. thirdPlaceAssignment— DONE (2026-06-28). All 8 slots filled from FIFA's official combination table; bracket verified. No longer pending.- Lighthouse > 90 run (needs a deployed URL).
- Post-Cup home state — when the Final goes
overthe hero is empty; build a champion/epilogue state (likely converges with the Stats screen). - Stats Stage G (Layer-2 cheap data —
cards→{y,r} migration is breaking formodal.js+stats.js; schedule LATE, conflicts with dailyresults.jsonedits), Stage H (players + the deferred comparator Teams/Players toggle), Stage I (editorial), Stage J round 2 polish. - PWA Tier 2 (service worker + offline) — deferred; must exclude
data/*.json(seeissues.md).
Success metrics
Lighthouse > 90; first render < 2s; total JS < 300KB (74 KB measured at build). Spec §18 acceptance criteria all checked (README checklist).
Communication
User communicates in an English/Portuguese mix; docs in English where practical (retained PT passages kept as written). Ask before each build step — never chain into the next without explicit go-ahead.