world-2026-hub/.agents/project-memory.md
Lucas Kalil ffda08229b docs(agents): document GitHub Actions FTP deploy pipeline
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:05:01 -03:00

24 KiB
Raw Blame History

Project Memory — World Cup 2026 Hub

Persistent memory for this project. Read this before any significant change.


Context

Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) — schedule, group standings, interactive knockout bracket with user simulation, stadiums. Hosted on GitHub Pages, all content from data/*.json. Started 2026-06-11 from two spec documents; built step-by-step with user approval between steps.

What this project is

  • A personal/portfolio piece — visual polish (glassmorphism, animations) is a primary goal, not a nice-to-have.
  • A static SPA: one index.html, ES-module vanilla JS, JSON as the only "database".
  • Maintained by editing JSON only — code should never need touching to update scores/teams.

What this project is not

  • No backend, no database, no build step, no bundler, no CDN dependencies, no frameworks.
  • No automated tests, no linter (explicit spec constraint).
  • Not real-data-complete: ships with mock data (fictional teams) to be replaced later.

Priority objectives

  1. Spec compliance — both spec files define scope; complement-spec-worldcup2026-en.md wins on conflict.
  2. Visual quality — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
  3. Interactive bracket — hover path highlight, zoom, drag, simulation mode; the centerpiece feature.
  4. Easy maintenance — real data drop-in via JSON; bracket-config.json is the only file edited after group stage.
  5. Performance/accessibility — Lighthouse > 90, first render < 2s, JS < 300KB, ARIA + keyboard nav.

Technical decisions and rationale

Stack

  • Vanilla HTML/CSS/JS ES2022+, ES Modules — spec mandate; GitHub Pages serves static files only. Frameworks/bundlers explicitly forbidden.
  • EN/PT-BR UI toggle via i18n.js (user decision 2026-06-11) — not in spec; spec UI examples are EN, user wants both. Tiny dict + t(key), persisted in wc2026_prefs.lang. Alternative (EN only) rejected by user.
  • storage.js pulled forward to step 2 (spec places it in phase 12) — prefs (lang, lastTab) are needed from the base-layout step; building it late would mean refactoring.

Data model

  • All match times in UTC in matches.json; converted at render via Intl.DateTimeFormat (formatMatchTime). .ics export depends on this.
  • Knockout matches carry bracketRef instead of teams — teams resolved at runtime from standings + bracket-config.json; rounds after R32 have no config, generated by sequential pairing of winners (indices 0-1 → 0, 2-3 → 1, …).
  • Simulation never mutates JSON — overlay stored in localStorage wc2026_simulation, keyed by bracket match ids (R32-1, QF-2, FINAL, …).

Mock data design (2026-06-11, step 1)

  • Real country names, fictional results — generated deterministically (seed 2026), script deleted after run; data is now static JSON.
  • State crafted to test both bracket modes: matchdays 1-2 all finished; matchday 3 finished for groups AF, scheduled for GL (match 61, Group G, is live). So R32 slots fed by AF resolve to real teams; GL and all third slots show placeholders.
  • Knockout results: R32-2 (match 74) finished 1-1 + penalties 4-3; R32-4 (match 76) finished 2-0. Everything else scheduled with null scores — results.json has an entry for all 104 matches.
  • Image paths: flag/image JSON values are relative to assets/images/ (e.g. flags/mex.svg, stadiums/azteca.svg).
  • Opener (match 1) is MEX at Estadio Azteca 2026-06-11; final (match 104) at MetLife 2026-07-19.

Base layout decisions (2026-06-11, step 2)

  • Hero priority: live > next scheduled — a live match replaces the countdown with the score + pulse badge. Mock data keeps match 61 permanently live, so the countdown only shows if that status is changed (verified working by temporarily setting it to scheduled).
  • i18n mechanics: static HTML uses data-i18n / data-i18n-aria attributes re-applied by applyI18n(); dynamic renders call t() and listen for the langchange event on document. Phases translate via translatePhase() (PT: R32 = "16 avos de final").
  • Tab routing: hash (#matches) + wc2026_prefs.lastTab, history.replaceState to avoid history spam; precedence on load: hash → lastTab → home.
  • Default language: navigator.language startsWith pt → PT, else EN; only persisted when the user clicks the toggle.
  • Preview server: .claude/launch.json at R:\lucas-kalil\Projects\ defines worldcup2026 (python http.server, port 8126) for the Claude Preview panel.

Schedule + performance decisions (2026-06-11, step 3)

  • schedule.jsapp.js circular import is intentionalapp.js calls initSchedule(), schedule.js imports getData/formatMatchTime/flagSrc back. Safe in native ESM because all calls happen after both modules evaluate; keep this pattern for groups.js/bracket.js/modal.js.
  • Filter UX: toolbar is rebuilt only on init/langchange/clear (state restored programmatically); list re-renders on every filter change. Filter state is in-memory only (not persisted) by design.
  • Knockout cards show "TBD" until step 7 swaps teamColumnHTML's lookup to resolveBracketTeams().
  • Perf: no backdrop-filter on repeated cards.match-card overrides .glass blur (huge paint cost × 104 cards, invisible over the smooth gradient). Same rule applies to any future card grid (stadiums, bracket).
  • Perf: fixed gradient lives on body::before (position: fixed), not background-attachment: fixed (the latter repaints the whole background on scroll).

Standings decisions (2026-06-11, step 4)

  • Only status: "finished" matches count toward standings — live scores are ignored until full-time (keeps standings stable and bracket resolution deterministic).
  • Tiebreak order: points → goal difference → goals for → team id alphabetical (stable fallback). Verified against an independent Python computation for groups A and G.
  • computeStandings() / isGroupFinished() exported from groups.js — bracket.js (step 7) must import these instead of recomputing.

Stadiums decisions (2026-06-11, step 5)

  • stadiums.js module added — spec §4 has no module for the stadiums view; a dedicated view module keeps the per-view pattern (schedule/groups/bracket) instead of growing app.js.
  • Cross-link: "View matches" on a stadium card calls setStadiumFilter(name) (exported by schedule.js, resets other filters) + navigateTo('matches') (exported by app.js).

Modal decisions (2026-06-11, step 6)

  • Native <dialog> + showModal() — focus trap, Esc-to-close and ::backdrop come free; no custom trap code. Backdrop click detected via event.target === dialog (content is in a padded inner div).
  • Focus restore: opener element saved in openMatchModal() and re-focused on close.
  • Card → modal wiring is event delegation on #schedule-root (click + Enter/Space keydown), so it survives list re-renders. Cards got tabindex="0" role="button" aria-label already in this step.
  • openMatchModal(matchId) is the public API — bracket (step 7) and any future view should call it rather than building their own.

Bracket decisions (2026-06-11, step 7)

  • Tree model is language-neutral: slots are { teamId } or { ph: { kind, … } }; placeholder text is produced at render time by slotDisplay() so language switches never invalidate the tree.
  • Tree is cached in bracket.js; invalidateBracket() exists for the simulation overlay (step 9). Results are static per page load, so nothing else invalidates it.
  • resolveBracketTeams(matchOrRef) accepts a match object (group or knockout) or a bracketRef string and always returns { home, away } as { team: Team|null, label: string } — schedule cards, modal, and search/team filters all consume this, so knockout matches become searchable/filterable once resolved.
  • Connector geometry: all bracket columns share equal height with flex: 1 slots, so pair children sit at 25%/75% and the next round's node at 50% — pure-CSS connectors (::before/::after stubs + pair vertical) meet exactly. Column gap 44px = 22px out-stub + 22px in-stub. Breaking the equal-height invariant breaks the lines.
  • Final column holds champion box (top), FINAL (middle, aligns with SF pair), third-place block (bottom); its champion/third slots suppress the incoming connector stub.

Bracket interaction decisions (2026-06-11, step 8)

  • "Full path" on hover/focus = hovered node + its entire feeder subtree + its winner's route to the FINAL; THIRD-PLACE lights both SFs. Non-path nodes dim via .has-path on the canvas. Computed from ref arithmetic (floor(i/2) up, 2i/2i+1 down), no tree lookup.
  • Zoom = CSS transform: scale() on the canvas + a #bracket-zoom box sized to natural × scale (transform doesn't affect layout, so the box gives the scroll container the right scrollable area). Pointer-anchored: scroll adjusted so the point under the cursor stays put. Clamped 0.42.
  • Natural canvas size is measured lazily (ensureMeasured()) because the bracket panel can be hidden at render time (offsetWidth 0).
  • Pan + pinch via Pointer Events with touch-action: none on the wrap (we own all gestures there; page scroll over the bracket is intentionally captured, like a map widget).
  • Dragclick conflict: >5px movement sets a flag; a capture-phase click listener on the wrap swallows the click that ends a drag. The flag resets on the next pointerdown, so synthetic el.click() without pointerdown can be falsely suppressed in tests — dispatch pointerdown/up first.
  • Zoom level survives langchange re-renders (module-level view.scale) but intentionally not reloads (not in prefs).

Simulation decisions (2026-06-11, step 9)

  • Separation of concerns in the tree: decide() applies only real finished results; applySimulation() overlays user picks afterwards and never overrides a real result. Stale entries (winner no longer among the resolved teams) are silently ignored — same validation the prediction import (step 12) will use.
  • Sim UX: "Simulation" toggle in the bracket toolbar; eligible nodes (both teams resolved, real result still scheduled) get dashed blue borders; clicking one opens a small native <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.
  • simchange custom event fires after any pick/reset; schedule.js listens and re-renders so simulated teams appear on knockout cards too (intentional leak — resolved tree is the single source).
  • Simulated nodes show a blue "SIM" corner chip and blue scores; the modal shows none of this (it reads real results only).

Responsive/a11y decisions (2026-06-11, step 10)

  • Breakpoints: ≤767 (tight spacing, bracket --node-w: 168px/gap 36px — connector stub offsets must stay at gap/2, overridden in the same media query), 7681439 (single-row header, centered menu), 1440+ (container widens to 1360px).
  • Tabs follow the WAI-ARIA pattern: roving tabindex + ArrowLeft/Right/Home/End in initTabs(); focus follows activation.
  • Dialogs get aria-label set at open time (match name + phase); schedule count is aria-live="polite"; countdown has role="timer" + label.
  • Entry animations: every unhidden .panel fades in; card grids (.match-grid/.groups-grid/.stadiums-grid > *) slide up with a 45ms stagger on the first 6 children, 260ms for the rest. All killed by prefers-reduced-motion.

Extra features decisions (2026-06-12, step 12 — done before step 11 at user request)

  • Favorites: single global capture-phase click delegation in app.js handles every .fav-btn (schedule, groups, modal) and dispatches favchange; each view re-renders itself. Stars never trigger the card/modal click (guard via closest('.fav-btn')). Bracket shows highlight only (no stars — nodes too small). Favorite involvement = gold left border.
  • getFavoriteMatches(matches, favorites) lives in bracket.js (needs resolveBracketTeams), imported by schedule.js for the "My matches" filter.
  • Time mode: header #time-toggle flips wc2026_prefs.timeMode and dispatches timemodechange; formatMatchTime() already defaulted to the pref, so views just re-render.
  • Challenge: sim entries for real-finished matches can't be created in the UI (locked) but old ones persist in storage — that's by design: predictions are made while matches are scheduled, then scored when results land. Card renders only when ≥1 finished knockout match exists.
  • Share/import: ?prediction= is stripped from the URL via history.replaceState whether applied or not (prevents re-prompt loops). Declining confirm() keeps local picks; unknown refs are rejected wholesale.
  • .ics: RFC 5545 TEXT escaping (\, etc.) applied even though the spec template shows raw commas — RFC compliance wins; verified output imports with CRLF-only endings.
  • Custom events now in play: langchange, simchange, favchange, timemodechange — all on document; views own their re-renders.

Build complete (2026-06-12, step 11 — all 12 steps done)

  • README is the user-facing manual (run, deploy, JSON maintenance, localStorage keys, acceptance checklist). Keep it in sync when data formats change.
  • Verified at completion: spec §18 criteria all pass; JS = 74 KB total (budget 300 KB); no root-absolute paths (GitHub Pages safe). Not yet verified: Lighthouse > 90 (needs a deployed URL or local Lighthouse run); actual GitHub Pages deploy.
  • No commits made yet — repo initialized but empty; commit when the user asks.

Workflow

  • 12-step build plan with approval gates — user approves each step before the next starts; summary after each step. Plan: C:\Users\Lucas\.claude\plans\read-r-lucas-kalil-projects-web-worldcup-goofy-meerkat.md.
  • Git repo, commits only when the user asks.

Known gotchas

1. fetch() of JSON fails on file://

Where: any local testing of index.html Why: browsers block fetch of local files (CORS/origin rules) Symptom if forgotten: blank app, console CORS errors, wasted debugging Solution: always serve via python -m http.server (or any static server) from the project root

2. .ics requires CRLF line endings

Where: assets/js/calendar.js Why: RFC 5545 mandates \r\n between lines; some calendar apps reject \n Symptom if forgotten: exported event silently fails to import in Outlook/Apple Calendar Solution: join VCALENDAR lines with \r\n explicitly

3. Third-place slots are null until defined

Where: data/bracket-config.jsonthirdPlaceAssignment 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

8. Claude Preview: resize beyond the native window breaks clicks/screenshots

Where: Claude Preview panel, preview_resize wider than the native window (~791 CSS px) Why: viewport emulation desyncs the capture/click surface from the page (screenshots show the page squeezed in the left half; preview_click lands on wrong coordinates and silently does nothing) Symptom if forgotten: "clicks don't work" / half-black screenshots at desktop widths, wasted debugging — the app itself is fine (preview_eval geometry confirms) Solution: at emulated widths > native, navigate via preview_eval + exported navigateTo() and verify geometry via eval/inspect; trust screenshots only at widths ≤ native. preview_resize preset: desktop resets to native and fixes clicks.


Patterns for future changes

How to update real-world data (scores, schedule)

Follow how-refresh-data.md (project root). In short:

  1. Edit data/results.json (scores/status) or data/matches.json (schedule, rare).
  2. Once group stage ends: fill data/bracket-config.jsonthirdPlaceAssignment (slot → group letter). Nothing else changes.

Real-data migration (2026-06-12)

  • how-update.md (project root) is the full runbook for replacing mock data/*.json with real World Cup 2026 data: file-by-file schemas, order of operations (stadiums → teams → groups → bracket-config.round32 → matches → results → thirdPlaceAssignment), and a cross-file integrity checklist (group membership, id ranges, bracketRef uniqueness, stadium name/city matches).
  • Flags one open decision: stadiums.json has 30 entries (original bid shortlist) vs. the 16 venues actually used by the real tournament — confirm with user whether to trim before/while editing matches.json.

Real-data migration DONE (2026-06-12)

  • All 6 data/*.json files now hold real WC2026 data. Sources: Wikipedia per-group + knockout-stage articles (primary; local kickoff times with explicit UTC offsets), cross-checked vs ESPN/NBC/FOX/olympics.com/lumenfield.com. Full smoke test + desktop/mobile layout pass, console clean.
  • Stadiums trimmed 30 → 16 (user decision). Cities use FIFA host-city names ("New York/New Jersey", "San Francisco Bay Area", "Boston"), not suburb names (East Rutherford, Santa Clara, Foxborough) — matches.json and stadiums.json must keep matching exactly.
  • Match ids: group matches 172 are chronological by UTC kickoff (≠ 6-per-group blocks); knockout ids 73104 are FIFA's official match numbers.
  • bracket-config app-order ↔ FIFA mapping: R32-1..16 = FIFA matches 74, 77, 73, 75, 83, 84, 81, 82, 76, 78, 79, 80, 86, 88, 85, 87 (ordering chosen so the app's sequential pairing reproduces the official R16/QF/SF progression).
  • Third-place slots → allowed groups (for filling thirdPlaceAssignment after the group stage, per official draw): slot 1 (M74) A/B/C/D/F · slot 2 (M77) C/D/F/G/H · slot 3 (M81) B/E/F/I/J · slot 4 (M82) A/E/H/I/J · slot 5 (M79) C/E/F/H/I · slot 6 (M80) E/H/I/J/K · slot 7 (M85) E/F/G/I/J · slot 8 (M87) D/E/I/J/L. Each group letter may appear in only one slot.
  • Results as of 2026-06-12: ids 13 finished (MEX 20 RSA, KOR 21 CZE, CAN 11 BIH); USAPAR (id 4) kicked off 01:00 UTC Jun 13 — first thing to update next session.

Daily refresh (2026-06-13)

  • Results updated through match id 6 (matchday 1 complete):
    • id 4: USA 41 PAR (Group D) — confirmed FIFA + Yahoo/ESPN
    • id 5: QAT 11 SUI (Group B) — confirmed FOX Sports + ESPN (Khoukhi 94th-min header)
    • id 6: BRA 11 MAR (Group C) — confirmed FOX Sports + NBC Sports (Saibari for MAR, Vinicius Jr. for BRA)
  • Verified standings in Groups view: Group B shows Qatar/Switzerland each 1 pt; Group C shows Brazil/Morocco each 1 pt; Group D confirms USA 3 pts (W 4-1), Paraguay 0 pts.
  • Single-source caveat: R16 match 94 (Jul 6, Lumen Field) time 17:00 PDT per Wikipedia; one ESPN summary implied 14:00 PDT. Re-verify when R16 nears.
  • Next: matches 78 scheduled Jun 14 (HAISCO, AUSTUR, both Group stage). Continue daily routine per how-refresh-data.md.

Daily refresh (2026-06-14)

  • Results updated through match id 7 (HAISCO):
    • id 7: HAI 01 SCO (Group C) — confirmed Outlook India + VAVEL USA (McGinn 28')
  • Verified standings in Groups view: Group C now shows Scotland 1W 3pts, Brazil/Morocco 1D 1pt each, Haiti 1L 0pts.
  • Next: matches 8+ scheduled Jun 14 (AUSTUR onwards). Continue daily routine.

Daily refresh runbook (2026-06-12)

  • how-refresh-data.md (project root) is the runbook for all updates during the tournament — read it before touching any data/*.json from now on. It defines: daily results.json routine (scores/status, two-source rule, penalties only on ids 73104), the one-time thirdPlaceAssignment fill (~Jun 2728, slot → allowed-groups table), and the frozen files (stadiums/teams/groups/round32/assets/code — never edit).
  • how-update.md stays as the schema reference for the (completed) mock → real migration; how-refresh-data.md supersedes it for day-to-day work.

CI/CD — deploy automático para Hostinger via FTP (2026-06-14)

  • GitHub Actions em .github/workflows/deploy.yml: a cada push em master (ou workflow_dispatch manual) envia o site pra Hostinger usando SamKirkland/FTP-Deploy-Action@v4.3.5.
  • Remote GitHub: origin = https://github.com/LucasKalil-Programador/world-2026-hub.git (branch master). Push via credential manager do Windows (gh CLI NÃO está instalado nesta máquina).
  • Secrets necessários no repo (Settings → Secrets and variables → Actions): FTP_SERVER, FTP_USERNAME, FTP_PASSWORD — vêm do hPanel da Hostinger (Files → FTP Accounts). Sem eles o workflow falha.
  • Config do workflow: protocol: ftps, port: 21, local-dir: ./, server-dir: public_html/ (ajustar se for subdomínio/addon domain). exclude remove do deploy: .git*, .github/, .agents/, README.md, how-*.md, specs *-en.md — só index.html + assets/ + data/ chegam ao site.
  • Sync incremental: a action mantém .ftp-deploy-sync-state.json no servidor; só reenvia arquivos alterados. Não comitar esse arquivo (vive só no servidor).
  • Gotcha: se a Hostinger não aceitar FTPS explícito, trocar protocol para ftp. Se o site ficar em subpasta, lembrar do gotcha #7 (paths relativos) — já está OK no projeto.

How to add a UI label

  1. Add the key to both en and pt dicts in assets/js/i18n.js.
  2. Use t("key") at the render site — never hardcode the string.

How to add a new localStorage preference

  1. Extend the wc2026_prefs object shape (document the new field here).
  2. Read/write only via storage.js get/set.

How to add a step summary after finishing a build step

  1. Mark the step [x] ~~...~~ in .agents/TODO.md.
  2. Append any new decisions/gotchas here (never rewrite existing entries).
  3. Rewrite project-map.md if structure/functions changed.
  4. Stop and wait for user approval before the next step.

Success metrics

  • Lighthouse > 90; first render < 2s; total JS < 300KB.
  • Spec §18 acceptance criteria all checked (tracked in README checklist, step 11).

Communication

  • User communicates in English/Portuguese mix; docs in English per conventions.
  • Ask before each build step — never chain into the next step without explicit go-ahead.