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

33 KiB
Raw Blame History

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 172 = chronological by UTC kickoff (≠ 6-per-group blocks); 73104 = 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-1R32-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.42; natural size measured lazily (ensureMeasured() — panel may be hidden at render). Pan/pinch via Pointer Events, touch-action:none on the wrap. Dragclick 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 <dialog> + 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 endtournamentOver() 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.jsrenderHome, schedule.jsrenderList, groups.jsrender, bracket.jsrender, 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), 7681100 (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 browserpython -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 73104) → 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 2728)

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.

Single source of truth: assets/js/i18n.js line 9const 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 AJ). The pure-UI build (AD + 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 + <nav.stats-subnav> (anchor chips) + one section per available section + footer. Chips are <a href="#stats-{id}"> 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="<id>"; a one-time capture-phase error listener 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() 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 148, favorites, team records

  • Canonical ranking 148 (assignRankscomputeRankTiers): 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-matchesnavigateTo('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 <select>s (alphabetical, 48 teams) default to the top-2 ranked; choice survives langchange (module-level cmpA/cmpB). On change, only the bars panel re-renders. Diverging mirrored bars scaled to max(a,b,1); higher side's number gold. Metrics CMP_METRICS are all non-negative (P, W, GF, GA, CS, Pts — GD excluded, it can be negative). cmp-grow scaleX animation, off under prefers-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 AF: 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 fade mask-image is on the track, so the pill's background/rounded ends stay crisp. .stats-subnav is overflow:hidden; updateSubnavFades(nav) toggles .fade-left/-right from 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) and updateSpy() early-returns while suppressed, so the clicked chip owns the active state until the smooth scroll settles.

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

  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 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

  1. Extend the wc2026_prefs shape (document the new field here).
  2. Read/write only via storage.js get/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() in app.js: a single position:fixed .app-tooltip via event delegation on document (so it survives re-renders and is never clipped by overflow-x:auto containers); 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 in i18n.js namespace tip.* (EN/PT), reused by both tables.
  • Mobile legend: <p class="stats-legend"> (display:none desktop, flex ≤600px) — covers touch where hover doesn't fire. legendHTML() in stats.js / groups.js. CSS lives in stats.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.21.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.071.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 "7681439 single-row header" note.

How to record a decision (after finishing a unit of work)

  1. Tick the item in .agents/TODO.md.
  2. 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).
  3. Rewrite project-map.md if structure/functions changed.

Current State

Updated 2026-06-18. Data: results through match 26/104 (26 of 72 group-stage matches finished; group stage in progress). thirdPlaceAssignment still all null (fill ~Jun 27). Cache-busting is now automatic (?t=Date.now(); DATA_VERSION removed 2026-06-18). APP_VERSION = v1.0.1. Build: all 12 steps + real-data migration done; Stats stages AD + F + J(r1) merged to master and live (E skipped).

Recent refreshes (rolling — keep the last 3, prune older; full detail in git)

  • 2026-06-18 (rev1rev3) — matches 2426: UZB 13 COL, CZE 11 RSA, SUI 41 BIH (all 4 goals came 74'+; FIFA/FOX still cached at 00 — score confirmed via ESPN report + Wikipedia minutes; card count 1/3 from FOX named bookings, ESPN box lagged at 0/1).
  • 2026-06-17 (rev1rev3) — matches 1823: IRQ 14 NOR, ARG 30 ALG, AUT 31 JOR, POR 11 COD, ENG 42 CRO, GHA 10 PAN.

Pending / next

  • thirdPlaceAssignment fill once the group stage ends (~Jun 27) — slot→group table above.
  • Lighthouse > 90 run (needs a deployed URL).
  • Post-Cup home state — when the Final goes over the 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 for modal.js + stats.js; schedule LATE, conflicts with daily results.json edits), 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 (see issues.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.