HASH 44e13f68c7c0
DATE 2026-05-03
SUBJECT web: home page carousels + season toggle
FILES 4 CHANGED
HASH 44e13f68c7c0
DATE 2026-05-03
SUBJECT web: home page carousels + season
toggle
FILES 4 CHANGED
┌─ web/src/components/Home/Carousel/Carousel.astro ──────────────────────────┐
│ diff --git a/web/src/components/Home/Carousel/Carousel.astro b/web/src/components/ │
│ Home/Carousel/Carousel.astro │
│ new file mode 100644 │
│ index 0000000..1ca89f0 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/Carousel/Carousel.astro │
│ @@ -0,0 +1,67 @@ │
│ +--- │
│ +import "./Carousel.scss"; │
│ + │
│ +interface Props { │
│ + // Accessible label for the carousel viewport. │
│ + ariaLabel: string; │
│ + // Number of cards visible per "page" (desktop). Card width is computed to │
│ + // exactly fit this many in the viewport. │
│ + perView: number; │
│ + // Per-view at narrow widths (? 768px). Defaults to 1. │
│ + perViewMobile?: number; │
│ + // Extra class on the outer container. │
│ + class?: string; │
│ +} │
│ + │
│ +const { │
│ + ariaLabel, │
│ + perView, │
│ + perViewMobile = 1, │
│ + class: extraClass, │
│ +} = Astro.props; │
│ + │
│ +const styleVars = `--carousel-per-view: ${perView}; --carousel-per-view-mobile: $ │
│ {perViewMobile};`; │
│ +--- │
│ + │
│ +<div class:list={["carousel", extraClass]} style={styleVars}> │
│ + <button │
│ + type="button" │
│ + class="carousel__arrow carousel__arrow--prev" │
│ + aria-label="Scroll left" │
│ + data-carousel-prev │
│ + hidden │
│ + > │
│ + <svg viewBox="0 0 24 24" aria-hidden="true"> │
│ + <path d="M15 6l-6 6 6 6" stroke="currentColor" stroke-width="2.4" stroke-li │
│ necap="round" stroke-linejoin="round" fill="none" /> │
│ + </svg> │
│ + </button> │
│ + │
│ + <div │
│ + class="carousel__viewport" │
│ + role="region" │
│ + aria-label={ariaLabel} │
│ + data-carousel-viewport │
│ + > │
│ + <div class="carousel__track"> │
│ + <slot /> │
│ + </div> │
│ + </div> │
│ + │
│ + <button │
│ + type="button" │
│ + class="carousel__arrow carousel__arrow--next" │
│ + aria-label="Scroll right" │
│ + data-carousel-next │
│ + hidden │
│ + > │
│ + <svg viewBox="0 0 24 24" aria-hidden="true"> │
│ + <path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="2.4" stroke-line │
│ cap="round" stroke-linejoin="round" fill="none" /> │
│ + </svg> │
│ + </button> │
│ +</div> │
│ + │
│ +<script> │
│ + import { initCarousels } from "./Carousel.ts"; │
│ + initCarousels(); │
│ + document.addEventListener("astro:page-load", () => initCarousels()); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../Home/Carousel/Carousel.astro ───┐
│ diff --git a/web/src/components/Home/Carouse │
│ l/Carousel.astro b/web/src/components/Home/C │
│ arousel/Carousel.astro │
│ new file mode 100644 │
│ index 0000000..1ca89f0 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/Carousel/Carou │
│ sel.astro │
│ @@ -0,0 +1,67 @@ │
│ +--- │
│ +import "./Carousel.scss"; │
│ + │
│ +interface Props { │
│ + // Accessible label for the carousel view │
│ port. │
│ + ariaLabel: string; │
│ + // Number of cards visible per "page" (de │
│ sktop). Card width is computed to │
│ + // exactly fit this many in the viewport. │
│ + perView: number; │
│ + // Per-view at narrow widths (? 768px). D │
│ efaults to 1. │
│ + perViewMobile?: number; │
│ + // Extra class on the outer container. │
│ + class?: string; │
│ +} │
│ + │
│ +const { │
│ + ariaLabel, │
│ + perView, │
│ + perViewMobile = 1, │
│ + class: extraClass, │
│ +} = Astro.props; │
│ + │
│ +const styleVars = `--carousel-per-view: ${p │
│ erView}; --carousel-per-view-mobile: ${perVi │
│ ewMobile};`; │
│ +--- │
│ + │
│ +<div class:list={["carousel", extraClass]} │
│ style={styleVars}> │
│ + <button │
│ + type="button" │
│ + class="carousel__arrow carousel__arrow- │
│ -prev" │
│ + aria-label="Scroll left" │
│ + data-carousel-prev │
│ + hidden │
│ + > │
│ + <svg viewBox="0 0 24 24" aria-hidden="t │
│ rue"> │
│ + <path d="M15 6l-6 6 6 6" stroke="curr │
│ entColor" stroke-width="2.4" stroke-linecap= │
│ "round" stroke-linejoin="round" fill="none" │
│ /> │
│ + </svg> │
│ + </button> │
│ + │
│ + <div │
│ + class="carousel__viewport" │
│ + role="region" │
│ + aria-label={ariaLabel} │
│ + data-carousel-viewport │
│ + > │
│ + <div class="carousel__track"> │
│ + <slot /> │
│ + </div> │
│ + </div> │
│ + │
│ + <button │
│ + type="button" │
│ + class="carousel__arrow carousel__arrow- │
│ -next" │
│ + aria-label="Scroll right" │
│ + data-carousel-next │
│ + hidden │
│ + > │
│ + <svg viewBox="0 0 24 24" aria-hidden="t │
│ rue"> │
│ + <path d="M9 6l6 6-6 6" stroke="curren │
│ tColor" stroke-width="2.4" stroke-linecap="r │
│ ound" stroke-linejoin="round" fill="none" /> │
│ + </svg> │
│ + </button> │
│ +</div> │
│ + │
│ +<script> │
│ + import { initCarousels } from "./Carousel │
│ .ts"; │
│ + initCarousels(); │
│ + document.addEventListener("astro:page-loa │
│ d", () => initCarousels()); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/Carousel/Carousel.scss ───────────────────────────┐
│ diff --git a/web/src/components/Home/Carousel/Carousel.scss b/web/src/components/H │
│ ome/Carousel/Carousel.scss │
│ new file mode 100644 │
│ index 0000000..a2805e9 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/Carousel/Carousel.scss │
│ @@ -0,0 +1,95 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.carousel { │
│ + position: relative; │
│ + --carousel-arrow-size: 40px; │
│ + --carousel-gap: #{$spacing-md}; │
│ + // Resolved per-view (defaults to whatever the consumer set; the mobile │
│ + // breakpoint below swaps in --carousel-per-view-mobile). │
│ + --carousel-pv: var(--carousel-per-view); │
│ + │
│ + &__viewport { │
│ + overflow-x: auto; │
│ + overflow-y: hidden; │
│ + scroll-snap-type: x mandatory; │
│ + -webkit-overflow-scrolling: touch; │
│ + scrollbar-width: none; // firefox │
│ + &::-webkit-scrollbar { │
│ + display: none; │
│ + } │
│ + } │
│ + │
│ + &__track { │
│ + display: flex; │
│ + gap: var(--carousel-gap); │
│ + } │
│ + │
│ + // Each child claims (1 / per-view) of the viewport width minus the gaps │
│ + // between visible cards, so exactly N fit with no fractional bleed. │
│ + &__track > * { │
│ + flex: 0 0 │
│ + calc( │
│ + (100% - (var(--carousel-pv) - 1) * var(--carousel-gap)) / │
│ + var(--carousel-pv) │
│ + ); │
│ + scroll-snap-align: start; │
│ + min-width: 0; │
│ + } │
│ + │
│ + &__arrow { │
│ + position: absolute; │
│ + top: 50%; │
│ + transform: translateY(-50%); │
│ + z-index: 2; │
│ + width: var(--carousel-arrow-size); │
│ + height: var(--carousel-arrow-size); │
│ + border-radius: 50%; │
│ + border: 1px solid var(--border-color); │
│ + background: var(--bg-secondary); │
│ + color: var(--text-primary); │
│ + cursor: pointer; │
│ + display: flex; │
│ + align-items: center; │
│ + justify-content: center; │
│ + padding: 0; │
│ + transition: │
│ + background 0.15s ease, │
│ + border-color 0.15s ease, │
│ + opacity 0.15s ease; │
│ + │
│ + &:hover { │
│ + background: var(--bg-primary); │
│ + border-color: var(--highlight-color); │
│ + color: var(--highlight-color); │
│ + } │
│ + │
│ + &[hidden] { │
│ + // `hidden` attribute is the source of truth - toggled by Carousel.ts when │
│ + // we hit either edge - but we also keep it visually inert so that screen │
│ + // readers and tab-focus skip it. │
│ + display: none; │
│ + } │
│ + │
│ + svg { │
│ + width: 60%; │
│ + height: 60%; │
│ + } │
│ + │
│ + &--prev { │
│ + left: -12px; │
│ + } │
│ + &--next { │
│ + right: -12px; │
│ + } │
│ + } │
│ + │
│ + // On narrow viewports the arrows would overlap card content; touch users can │
│ + // swipe natively so we just hide them entirely. We also swap the per-view to │
│ + // the mobile fallback so cards fill the viewport one at a time. │
│ + @media (max-width: 768px) { │
│ + --carousel-pv: var(--carousel-per-view-mobile); │
│ + &__arrow { │
│ + display: none !important; │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...s/Home/Carousel/Carousel.scss ───┐
│ diff --git a/web/src/components/Home/Carouse │
│ l/Carousel.scss b/web/src/components/Home/Ca │
│ rousel/Carousel.scss │
│ new file mode 100644 │
│ index 0000000..a2805e9 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/Carousel/Carou │
│ sel.scss │
│ @@ -0,0 +1,95 @@ │
│ +@use "../../../styles/core/tokens" as *; │
│ + │
│ +.carousel { │
│ + position: relative; │
│ + --carousel-arrow-size: 40px; │
│ + --carousel-gap: #{$spacing-md}; │
│ + // Resolved per-view (defaults to whateve │
│ r the consumer set; the mobile │
│ + // breakpoint below swaps in --carousel-p │
│ er-view-mobile). │
│ + --carousel-pv: var(--carousel-per-view); │
│ + │
│ + &__viewport { │
│ + overflow-x: auto; │
│ + overflow-y: hidden; │
│ + scroll-snap-type: x mandatory; │
│ + -webkit-overflow-scrolling: touch; │
│ + scrollbar-width: none; // firefox │
│ + &::-webkit-scrollbar { │
│ + display: none; │
│ + } │
│ + } │
│ + │
│ + &__track { │
│ + display: flex; │
│ + gap: var(--carousel-gap); │
│ + } │
│ + │
│ + // Each child claims (1 / per-view) of th │
│ e viewport width minus the gaps │
│ + // between visible cards, so exactly N fi │
│ t with no fractional bleed. │
│ + &__track > * { │
│ + flex: 0 0 │
│ + calc( │
│ + (100% - (var(--carousel-pv) - 1) * │
│ var(--carousel-gap)) / │
│ + var(--carousel-pv) │
│ + ); │
│ + scroll-snap-align: start; │
│ + min-width: 0; │
│ + } │
│ + │
│ + &__arrow { │
│ + position: absolute; │
│ + top: 50%; │
│ + transform: translateY(-50%); │
│ + z-index: 2; │
│ + width: var(--carousel-arrow-size); │
│ + height: var(--carousel-arrow-size); │
│ + border-radius: 50%; │
│ + border: 1px solid var(--border-color); │
│ + background: var(--bg-secondary); │
│ + color: var(--text-primary); │
│ + cursor: pointer; │
│ + display: flex; │
│ + align-items: center; │
│ + justify-content: center; │
│ + padding: 0; │
│ + transition: │
│ + background 0.15s ease, │
│ + border-color 0.15s ease, │
│ + opacity 0.15s ease; │
│ + │
│ + &:hover { │
│ + background: var(--bg-primary); │
│ + border-color: var(--highlight-color); │
│ + color: var(--highlight-color); │
│ + } │
│ + │
│ + &[hidden] { │
│ + // `hidden` attribute is the source o │
│ f truth - toggled by Carousel.ts when │
│ + // we hit either edge - but we also k │
│ eep it visually inert so that screen │
│ + // readers and tab-focus skip it. │
│ + display: none; │
│ + } │
│ + │
│ + svg { │
│ + width: 60%; │
│ + height: 60%; │
│ + } │
│ + │
│ + &--prev { │
│ + left: -12px; │
│ + } │
│ + &--next { │
│ + right: -12px; │
│ + } │
│ + } │
│ + │
│ + // On narrow viewports the arrows would o │
│ verlap card content; touch users can │
│ + // swipe natively so we just hide them en │
│ tirely. We also swap the per-view to │
│ + // the mobile fallback so cards fill the │
│ viewport one at a time. │
│ + @media (max-width: 768px) { │
│ + --carousel-pv: var(--carousel-per-view- │
│ mobile); │
│ + &__arrow { │
│ + display: none !important; │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/components/Home/Carousel/Carousel.ts ─────────────────────────────┐
│ diff --git a/web/src/components/Home/Carousel/Carousel.ts b/web/src/components/Hom │
│ e/Carousel/Carousel.ts │
│ new file mode 100644 │
│ index 0000000..8255da7 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/Carousel/Carousel.ts │
│ @@ -0,0 +1,58 @@ │
│ +// Wires up arrow buttons + edge-aware visibility for each .carousel on the page. │
│ +// Native horizontal scroll handles touch/swipe; this script just adds desktop │
│ +// arrow controls and toggles them when the user hits either end. │
│ + │
│ +function initCarousel(root: HTMLElement) { │
│ + const viewport = root.querySelector<HTMLElement>("[data-carousel-viewport]"); │
│ + const prev = root.querySelector<HTMLButtonElement>("[data-carousel-prev]"); │
│ + const next = root.querySelector<HTMLButtonElement>("[data-carousel-next]"); │
│ + if (!viewport || !prev || !next) return; │
│ + │
│ + // Scroll by ~80% of viewport width so the user gets a fresh page of cards │
│ + // each click while keeping a small overlap for context. │
│ + function scrollDelta(): number { │
│ + return Math.max(160, Math.floor(viewport!.clientWidth * 0.8)); │
│ + } │
│ + │
│ + function updateArrows() { │
│ + const max = viewport!.scrollWidth - viewport!.clientWidth; │
│ + const x = viewport!.scrollLeft; │
│ + // Tolerance for sub-pixel rounding - at exact edges browsers may report │
│ + // 0.5px off. │
│ + const atStart = x <= 1; │
│ + const atEnd = x >= max - 1; │
│ + // Hide both arrows when the track fits in one viewport (nothing to scroll). │
│ + const overflows = max > 1; │
│ + prev!.hidden = !overflows || atStart; │
│ + next!.hidden = !overflows || atEnd; │
│ + } │
│ + │
│ + prev.addEventListener("click", () => { │
│ + viewport.scrollBy({ left: -scrollDelta(), behavior: "smooth" }); │
│ + }); │
│ + next.addEventListener("click", () => { │
│ + viewport.scrollBy({ left: scrollDelta(), behavior: "smooth" }); │
│ + }); │
│ + │
│ + viewport.addEventListener("scroll", updateArrows, { passive: true }); │
│ + │
│ + // ResizeObserver picks up width changes (window resize, font load, etc.) so │
│ + // we re-evaluate whether arrows are needed. │
│ + const ro = new ResizeObserver(updateArrows); │
│ + ro.observe(viewport); │
│ + // Also recheck after images load - late-arriving images can change scrollWidth │
│ . │
│ + viewport.querySelectorAll("img").forEach((img) => { │
│ + if (!img.complete) img.addEventListener("load", updateArrows, { once: true }) │
│ ; │
│ + }); │
│ + │
│ + updateArrows(); │
│ +} │
│ + │
│ +export function initCarousels() { │
│ + const carousels = document.querySelectorAll<HTMLElement>(".carousel"); │
│ + carousels.forEach((c) => { │
│ + if (c.dataset.carouselInited === "1") return; │
│ + c.dataset.carouselInited = "1"; │
│ + initCarousel(c); │
│ + }); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/Home/Carousel/Carousel.ts ───┐
│ diff --git a/web/src/components/Home/Carouse │
│ l/Carousel.ts b/web/src/components/Home/Caro │
│ usel/Carousel.ts │
│ new file mode 100644 │
│ index 0000000..8255da7 │
│ --- /dev/null │
│ +++ b/web/src/components/Home/Carousel/Carou │
│ sel.ts │
│ @@ -0,0 +1,58 @@ │
│ +// Wires up arrow buttons + edge-aware visi │
│ bility for each .carousel on the page. │
│ +// Native horizontal scroll handles touch/s │
│ wipe; this script just adds desktop │
│ +// arrow controls and toggles them when the │
│ user hits either end. │
│ + │
│ +function initCarousel(root: HTMLElement) { │
│ + const viewport = root.querySelector<HTMLE │
│ lement>("[data-carousel-viewport]"); │
│ + const prev = root.querySelector<HTMLButto │
│ nElement>("[data-carousel-prev]"); │
│ + const next = root.querySelector<HTMLButto │
│ nElement>("[data-carousel-next]"); │
│ + if (!viewport || !prev || !next) return; │
│ + │
│ + // Scroll by ~80% of viewport width so th │
│ e user gets a fresh page of cards │
│ + // each click while keeping a small overl │
│ ap for context. │
│ + function scrollDelta(): number { │
│ + return Math.max(160, Math.floor(viewpor │
│ t!.clientWidth * 0.8)); │
│ + } │
│ + │
│ + function updateArrows() { │
│ + const max = viewport!.scrollWidth - vie │
│ wport!.clientWidth; │
│ + const x = viewport!.scrollLeft; │
│ + // Tolerance for sub-pixel rounding - a │
│ t exact edges browsers may report │
│ + // 0.5px off. │
│ + const atStart = x <= 1; │
│ + const atEnd = x >= max - 1; │
│ + // Hide both arrows when the track fits │
│ in one viewport (nothing to scroll). │
│ + const overflows = max > 1; │
│ + prev!.hidden = !overflows || atStart; │
│ + next!.hidden = !overflows || atEnd; │
│ + } │
│ + │
│ + prev.addEventListener("click", () => { │
│ + viewport.scrollBy({ left: -scrollDelta( │
│ ), behavior: "smooth" }); │
│ + }); │
│ + next.addEventListener("click", () => { │
│ + viewport.scrollBy({ left: scrollDelta() │
│ , behavior: "smooth" }); │
│ + }); │
│ + │
│ + viewport.addEventListener("scroll", updat │
│ eArrows, { passive: true }); │
│ + │
│ + // ResizeObserver picks up width changes │
│ (window resize, font load, etc.) so │
│ + // we re-evaluate whether arrows are need │
│ ed. │
│ + const ro = new ResizeObserver(updateArrow │
│ s); │
│ + ro.observe(viewport); │
│ + // Also recheck after images load - late- │
│ arriving images can change scrollWidth. │
│ + viewport.querySelectorAll("img").forEach( │
│ (img) => { │
│ + if (!img.complete) img.addEventListener │
│ ("load", updateArrows, { once: true }); │
│ + }); │
│ + │
│ + updateArrows(); │
│ +} │
│ + │
│ +export function initCarousels() { │
│ + const carousels = document.querySelectorA │
│ ll<HTMLElement>(".carousel"); │
│ + carousels.forEach((c) => { │
│ + if (c.dataset.carouselInited === "1") r │
│ eturn; │
│ + c.dataset.carouselInited = "1"; │
│ + initCarousel(c); │
│ + }); │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/pages/index.astro ────────────────────────────────────────────────┐
│ diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro │
│ index 298fa72..020b544 100644 │
│ --- a/web/src/pages/index.astro │
│ +++ b/web/src/pages/index.astro │
│ @@ -5,6 +5,7 @@ import LeaderboardTypeNav from "../components/Leaderboard/Leaderbo │
│ ardTypeNav/Lea │
│ import RecentRuns from "../components/Home/RecentRuns/RecentRuns.astro"; │
│ import DungeonRecordCard from "../components/Home/DungeonRecordCard/DungeonRecord │
│ Card.astro"; │
│ import TopPlayersList from "../components/Home/TopPlayersList/TopPlayersList.astr │
│ o"; │
│ +import Carousel from "../components/Home/Carousel/Carousel.astro"; │
│ │
│ export const prerender = false; │
│ │
│ @@ -18,20 +19,27 @@ try { │
│ console.error("[Home] Failed to load home.json:", error); │
│ } │
│ │
│ -// Pick the latest season (highest key) as the focus │
│ -const seasonKeys = home ? Object.keys(home.seasons).sort((a, b) => parseInt(b) - │
│ parseInt(a)) : []; │
│ -const currentSeasonKey = seasonKeys[0]; │
│ -const currentSeason = currentSeasonKey ? home.seasons[currentSeasonKey] : null; │
│ - │
│ -const recentRuns = home?.recent_top_runs ?? []; │
│ - │
│ -// Sort dungeons by ID for consistent layout │
│ -const dungeonEntries = currentSeason │
│ - ? Object.entries(currentSeason.top_runs_per_dungeon as Record<string, any[]>) │
│ - .filter(([_, runs]) => runs && runs.length > 0) │
│ - .sort(([_, a], [__, b]) => (a[0]?.dungeon_id ?? 0) - (b[0]?.dungeon_id ?? 0 │
│ )) │
│ +// Seasons sorted descending so the latest is the default tab. │
│ +const seasonKeys = home │
│ + ? Object.keys(home.seasons).sort((a, b) => parseInt(b) - parseInt(a)) │
│ : []; │
│ +const defaultSeasonKey = seasonKeys[0]; │
│ + │
│ +// Pre-compute each season's panel data so we can render all of them and toggle │
│ +// visibility client-side - keeps the season switch instant and avoids a refetch. │
│ +const seasons = seasonKeys.map((key) => { │
│ + const s = home.seasons[key]; │
│ + return { │
│ + key, │
│ + label: s.season_name || `Season ${key}`, │
│ + data: s, │
│ + dungeonEntries: Object.entries(s.top_runs_per_dungeon as Record<string, any[] │
│ >) │
│ + .filter(([, runs]) => runs && runs.length > 0) │
│ + .sort(([, a], [, b]) => (a[0]?.dungeon_id ?? 0) - (b[0]?.dungeon_id ?? 0)), │
│ + }; │
│ +}); │
│ │
│ +const recentRuns = home?.recent_top_runs ?? []; │
│ const generatedAt = home?.generated_at ? new Date(home.generated_at) : null; │
│ --- │
│ │
│ @@ -65,66 +73,96 @@ const generatedAt = home?.generated_at ? new Date(home.generat │
│ ed_at) : null; │
│ │
│ <LeaderboardTypeNav │
│ currentTab="home" │
│ - currentSeason={parseInt(currentSeasonKey)} │
│ + currentSeason={parseInt(defaultSeasonKey)} │
│ /> │
│ │
│ - {currentSeason && ( │
│ - <section class="home__section"> │
│ - <header class="home__section-header"> │
│ - <h2>Top Players - {currentSeason.season_name || `Season ${current │
│ SeasonKey}`}</h2> │
│ - </header> │
│ - <div class="home__players-grid"> │
│ - <TopPlayersList │
│ - title="Global" │
│ - scope="global" │
│ - players={currentSeason.top_players.global} │
│ - viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /global`} │
│ - /> │
│ - <TopPlayersList │
│ - title="US" │
│ - scope="regional" │
│ - players={currentSeason.top_players.us} │
│ - viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /us`} │
│ - /> │
│ - <TopPlayersList │
│ - title="EU" │
│ - scope="regional" │
│ - players={currentSeason.top_players.eu} │
│ - viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /eu`} │
│ - /> │
│ - <TopPlayersList │
│ - title="KR" │
│ - scope="regional" │
│ - players={currentSeason.top_players.kr} │
│ - viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /kr`} │
│ - /> │
│ - <TopPlayersList │
│ - title="TW" │
│ - scope="regional" │
│ - players={currentSeason.top_players.tw} │
│ - viewAllHref={`/challenge-mode/season${currentSeasonKey}/players │
│ /tw`} │
│ - /> │
│ - </div> │
│ - </section> │
│ + {seasons.length > 1 && ( │
│ + <nav class="home__season-tabs" role="tablist" aria-label="Season"> │
│ + {seasons.map((s) => ( │
│ + <button │
│ + type="button" │
│ + class:list={["home__season-tab", { active: s.key === defaultSea │
│ sonKey }]} │
│ + role="tab" │
│ + aria-selected={s.key === defaultSeasonKey ? "true" : "false"} │
│ + data-season-key={s.key} │
│ + > │
│ + {s.label} │
│ + </button> │
│ + ))} │
│ + </nav> │
│ )} │
│ │
│ - {currentSeason && ( │
│ - <section class="home__section"> │
│ - <header class="home__section-header"> │
│ - <h2>Dungeon Records - {currentSeason.season_name || `Season ${cur │
│ rentSeasonKey}`}</h2> │
│ - </header> │
│ - <div class="home__dungeon-grid"> │
│ - {dungeonEntries.map(([slug, runs]) => ( │
│ - <DungeonRecordCard │
│ - dungeonName={runs[0].dungeon_name} │
│ - dungeonSlug={slug} │
│ - runs={runs} │
│ - seasonId={parseInt(currentSeasonKey)} │
│ + {seasons.map((s) => ( │
│ + <div │
│ + class:list={["home__season-panel", { hidden: s.key !== defaultSeaso │
│ nKey }]} │
│ + data-season-panel={s.key} │
│ + aria-hidden={s.key !== defaultSeasonKey ? "true" : "false"} │
│ + > │
│ + <section class="home__section"> │
│ + <header class="home__section-header"> │
│ + <h2>Top Players - {s.label}</h2> │
│ + </header> │
│ + <Carousel │
│ + ariaLabel={`Top players by region - ${s.label}`} │
│ + class="home__players-carousel" │
│ + perView={3} │
│ + perViewMobile={1} │
│ + > │
│ + <TopPlayersList │
│ + title="Global" │
│ + scope="global" │
│ + players={s.data.top_players.global} │
│ + viewAllHref={`/challenge-mode/season${s.key}/players/global`} │
│ /> │
│ - ))} │
│ - </div> │
│ - </section> │
│ - )} │
│ + <TopPlayersList │
│ + title="US" │
│ + scope="regional" │
│ + players={s.data.top_players.us} │
│ + viewAllHref={`/challenge-mode/season${s.key}/players/us`} │
│ + /> │
│ + <TopPlayersList │
│ + title="EU" │
│ + scope="regional" │
│ + players={s.data.top_players.eu} │
│ + viewAllHref={`/challenge-mode/season${s.key}/players/eu`} │
│ + /> │
│ + <TopPlayersList │
│ + title="KR" │
│ + scope="regional" │
│ + players={s.data.top_players.kr} │
│ + viewAllHref={`/challenge-mode/season${s.key}/players/kr`} │
│ + /> │
│ + <TopPlayersList │
│ + title="TW" │
│ + scope="regional" │
│ + players={s.data.top_players.tw} │
│ + viewAllHref={`/challenge-mode/season${s.key}/players/tw`} │
│ + /> │
│ + </Carousel> │
│ + </section> │
│ + │
│ + <section class="home__section"> │
│ + <header class="home__section-header"> │
│ + <h2>Dungeon Records - {s.label}</h2> │
│ + </header> │
│ + <Carousel │
│ + ariaLabel={`Dungeon records - ${s.label}`} │
│ + class="home__dungeons-carousel" │
│ + perView={2} │
│ + perViewMobile={1} │
│ + > │
│ + {s.dungeonEntries.map(([slug, runs]) => ( │
│ + <DungeonRecordCard │
│ + dungeonName={runs[0].dungeon_name} │
│ + dungeonSlug={slug} │
│ + runs={runs} │
│ + seasonId={parseInt(s.key)} │
│ + /> │
│ + ))} │
│ + </Carousel> │
│ + </section> │
│ + </div> │
│ + ))} │
│ │
│ <section class="home__section"> │
│ <RecentRuns runs={recentRuns} /> │
│ @@ -135,6 +173,37 @@ const generatedAt = home?.generated_at ? new Date(home.genera │
│ ted_at) : null; │
│ </div> │
│ </PageLayout> │
│ │
│ +<script> │
│ + // Season tabs toggle which season-panel is visible. Both panels are │
│ + // server-rendered so the switch is instant - no fetch, no re-render. │
│ + function wireSeasonTabs() { │
│ + const tabs = document.querySelectorAll<HTMLButtonElement>(".home__season-tab" │
│ ); │
│ + const panels = document.querySelectorAll<HTMLElement>(".home__season-panel"); │
│ + if (tabs.length === 0) return; │
│ + tabs.forEach((tab) => { │
│ + tab.addEventListener("click", () => { │
│ + const key = tab.dataset.seasonKey; │
│ + if (!key) return; │
│ + tabs.forEach((t) => { │
│ + const active = t === tab; │
│ + t.classList.toggle("active", active); │
│ + t.setAttribute("aria-selected", active ? "true" : "false"); │
│ + }); │
│ + panels.forEach((p) => { │
│ + const isActive = p.dataset.seasonPanel === key; │
│ + p.classList.toggle("hidden", !isActive); │
│ + p.setAttribute("aria-hidden", isActive ? "false" : "true"); │
│ + }); │
│ + // Carousel arrows depend on viewport scrollWidth, which is 0 while the │
│ + // panel is hidden - dispatch resize so they re-evaluate after reveal. │
│ + window.dispatchEvent(new Event("resize")); │
│ + }); │
│ + }); │
│ + } │
│ + wireSeasonTabs(); │
│ + document.addEventListener("astro:page-load", wireSeasonTabs); │
│ +</script> │
│ + │
│ <style lang="scss"> │
│ @use "../styles/core/tokens" as *; │
│ │
│ @@ -186,12 +255,61 @@ const generatedAt = home?.generated_at ? new Date(home.gener │
│ ated_at) : null; │
│ font-size: $font-size-sm; │
│ } │
│ │
│ + // Season toggle - pill-style tabs matching the stats page region tabs so the │
│ + // page-level filter feels consistent across the site. │
│ + .home__season-tabs { │
│ + display: flex; │
│ + flex-wrap: wrap; │
│ + gap: $spacing-xs; │
│ + margin-bottom: $spacing-lg; │
│ + } │
│ + │
│ + .home__season-tab { │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + color: var(--text-secondary); │
│ + padding: 6px 14px; │
│ + cursor: pointer; │
│ + font-size: $font-size-sm; │
│ + font-weight: $font-weight-semibold; │
│ + border-radius: $radius-md; │
│ + transition: │
│ + color 0.15s ease, │
│ + background 0.15s ease, │
│ + border-color 0.15s ease; │
│ + │
│ + &:hover { │
│ + color: var(--text-primary); │
│ + border-color: var(--highlight-color); │
│ + } │
│ + │
│ + &.active { │
│ + color: var(--bg-primary); │
│ + background: var(--highlight-color); │
│ + border-color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + .home__season-panel { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-2xl; │
│ + │
│ + &.hidden { │
│ + display: none; │
│ + } │
│ + } │
│ + │
│ .home__section { │
│ display: flex; │
│ flex-direction: column; │
│ gap: $spacing-lg; │
│ │
│ - & + & { │
│ + & + &, │
│ + // The Recent Runs section sits as a sibling of the season-panel wrapper, │
│ + // not another `.home__section`, so the adjacency selector above wouldn't │
│ + // reach it without this second rule. Keeps section rhythm consistent. │
│ + .home__season-panel + & { │
│ margin-top: $spacing-2xl; │
│ } │
│ } │
│ @@ -205,35 +323,8 @@ const generatedAt = home?.generated_at ? new Date(home.genera │
│ ted_at) : null; │
│ } │
│ } │
│ │
│ - // Flexbox (not grid) so an incomplete last row centers horizontally. │
│ - // The `:global()` is needed because Astro scopes `> *` to local elements, │
│ - // but the children are component roots from DungeonRecordCard / TopPlayersList │
│ . │
│ - .home__dungeon-grid { │
│ - display: flex; │
│ - flex-wrap: wrap; │
│ - justify-content: center; │
│ - gap: $spacing-md $spacing-lg; │
│ - } │
│ - │
│ - .home__dungeon-grid > :global(.dungeon-record-card) { │
│ - flex: 1 1 calc(50% - #{$spacing-lg * 0.5}); │
│ - max-width: calc(50% - #{$spacing-lg * 0.5}); │
│ - min-width: 0; │
│ - } │
│ - │
│ - .home__players-grid { │
│ - display: flex; │
│ - flex-wrap: wrap; │
│ - justify-content: center; │
│ - gap: $spacing-lg; │
│ - } │
│ - │
│ - .home__players-grid > :global(.top-players-list) { │
│ - // 1px fudge to avoid sub-pixel rounding pushing the 3rd item to a new row │
│ - flex: 1 1 calc(33.333% - #{$spacing-lg * 2 * 0.333} - 1px); │
│ - max-width: calc(33.333% - #{$spacing-lg * 2 * 0.333} - 1px); │
│ - min-width: 280px; │
│ - } │
│ + // Carousel handles per-card sizing via its `perView` prop (see Carousel.scss). │
│ + // No per-card overrides needed here. │
│ │
│ // Mobile overrides - placed at the end of the stylesheet so they win the │
│ // cascade over the desktop rules above (same selector specificity). │
│ @@ -246,21 +337,5 @@ const generatedAt = home?.generated_at ? new Date(home.genera │
│ ted_at) : null; │
│ .home__hero h1 { │
│ font-size: 1.5em; │
│ } │
│ - │
│ - // Stack dungeon cards full-width on mobile so TeamComposition's vertical │
│ - // member stack (its own ~720px breakpoint) has room to read; the 2-col 165px │
│ - // grid otherwise overlapped player names with time/date. │
│ - .home__dungeon-grid > :global(.dungeon-record-card) { │
│ - flex: 1 1 100%; │
│ - max-width: 100%; │
│ - } │
│ - │
│ - // Player lists also fill the row; otherwise the 280px min-width caps each │
│ - // list below the available content area, leaving wasted right-margin. │
│ - .home__players-grid > :global(.top-players-list) { │
│ - flex: 1 1 100%; │
│ - max-width: 100%; │
│ - min-width: 0; │
│ - } │
│ } │
│ </style> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/pages/index.astro ──────────┐
│ diff --git a/web/src/pages/index.astro b/web │
│ /src/pages/index.astro │
│ index 298fa72..020b544 100644 │
│ --- a/web/src/pages/index.astro │
│ +++ b/web/src/pages/index.astro │
│ @@ -5,6 +5,7 @@ import LeaderboardTypeNav fr │
│ om "../components/Leaderboard/LeaderboardTyp │
│ eNav/Lea │
│ import RecentRuns from "../components/Home/ │
│ RecentRuns/RecentRuns.astro"; │
│ import DungeonRecordCard from "../component │
│ s/Home/DungeonRecordCard/DungeonRecordCard.a │
│ stro"; │
│ import TopPlayersList from "../components/H │
│ ome/TopPlayersList/TopPlayersList.astro"; │
│ +import Carousel from "../components/Home/Ca │
│ rousel/Carousel.astro"; │
│ │
│ export const prerender = false; │
│ │
│ @@ -18,20 +19,27 @@ try { │
│ console.error("[Home] Failed to load home │
│ .json:", error); │
│ } │
│ │
│ -// Pick the latest season (highest key) as │
│ the focus │
│ -const seasonKeys = home ? Object.keys(home. │
│ seasons).sort((a, b) => parseInt(b) - parseI │
│ nt(a)) : []; │
│ -const currentSeasonKey = seasonKeys[0]; │
│ -const currentSeason = currentSeasonKey ? ho │
│ me.seasons[currentSeasonKey] : null; │
│ - │
│ -const recentRuns = home?.recent_top_runs ?? │
│ []; │
│ - │
│ -// Sort dungeons by ID for consistent layou │
│ t │
│ -const dungeonEntries = currentSeason │
│ - ? Object.entries(currentSeason.top_runs_p │
│ er_dungeon as Record<string, any[]>) │
│ - .filter(([_, runs]) => runs && runs.l │
│ ength > 0) │
│ - .sort(([_, a], [__, b]) => (a[0]?.dun │
│ geon_id ?? 0) - (b[0]?.dungeon_id ?? 0)) │
│ +// Seasons sorted descending so the latest │
│ is the default tab. │
│ +const seasonKeys = home │
│ + ? Object.keys(home.seasons).sort((a, b) = │
│ > parseInt(b) - parseInt(a)) │
│ : []; │
│ +const defaultSeasonKey = seasonKeys[0]; │
│ + │
│ +// Pre-compute each season's panel data so │
│ we can render all of them and toggle │
│ +// visibility client-side - keeps the seaso │
│ n switch instant and avoids a refetch. │
│ +const seasons = seasonKeys.map((key) => { │
│ + const s = home.seasons[key]; │
│ + return { │
│ + key, │
│ + label: s.season_name || `Season ${key}` │
│ , │
│ + data: s, │
│ + dungeonEntries: Object.entries(s.top_ru │
│ ns_per_dungeon as Record<string, any[]>) │
│ + .filter(([, runs]) => runs && runs.le │
│ ngth > 0) │
│ + .sort(([, a], [, b]) => (a[0]?.dungeo │
│ n_id ?? 0) - (b[0]?.dungeon_id ?? 0)), │
│ + }; │
│ +}); │
│ │
│ +const recentRuns = home?.recent_top_runs ?? │
│ []; │
│ const generatedAt = home?.generated_at ? ne │
│ w Date(home.generated_at) : null; │
│ --- │
│ │
│ @@ -65,66 +73,96 @@ const generatedAt = home │
│ ?.generated_at ? new Date(home.generated_at) │
│ : null; │
│ │
│ <LeaderboardTypeNav │
│ currentTab="home" │
│ - currentSeason={parseInt(current │
│ SeasonKey)} │
│ + currentSeason={parseInt(default │
│ SeasonKey)} │
│ /> │
│ │
│ - {currentSeason && ( │
│ - <section class="home__section"> │
│ - <header class="home__section- │
│ header"> │
│ - <h2>Top Players - {currentS │
│ eason.season_name || `Season ${currentSeason │
│ Key}`}</h2> │
│ - </header> │
│ - <div class="home__players-gri │
│ d"> │
│ - <TopPlayersList │
│ - title="Global" │
│ - scope="global" │
│ - players={currentSeason.to │
│ p_players.global} │
│ - viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/globa │
│ l`} │
│ - /> │
│ - <TopPlayersList │
│ - title="US" │
│ - scope="regional" │
│ - players={currentSeason.to │
│ p_players.us} │
│ - viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/us`} │
│ - /> │
│ - <TopPlayersList │
│ - title="EU" │
│ - scope="regional" │
│ - players={currentSeason.to │
│ p_players.eu} │
│ - viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/eu`} │
│ - /> │
│ - <TopPlayersList │
│ - title="KR" │
│ - scope="regional" │
│ - players={currentSeason.to │
│ p_players.kr} │
│ - viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/kr`} │
│ - /> │
│ - <TopPlayersList │
│ - title="TW" │
│ - scope="regional" │
│ - players={currentSeason.to │
│ p_players.tw} │
│ - viewAllHref={`/challenge- │
│ mode/season${currentSeasonKey}/players/tw`} │
│ - /> │
│ - </div> │
│ - </section> │
│ + {seasons.length > 1 && ( │
│ + <nav class="home__season-tabs" │
│ role="tablist" aria-label="Season"> │
│ + {seasons.map((s) => ( │
│ + <button │
│ + type="button" │
│ + class:list={["home__seaso │
│ n-tab", { active: s.key === defaultSeasonKey │
│ }]} │
│ + role="tab" │
│ + aria-selected={s.key === │
│ defaultSeasonKey ? "true" : "false"} │
│ + data-season-key={s.key} │
│ + > │
│ + {s.label} │
│ + </button> │
│ + ))} │
│ + </nav> │
│ )} │
│ │
│ - {currentSeason && ( │
│ - <section class="home__section"> │
│ - <header class="home__section- │
│ header"> │
│ - <h2>Dungeon Records - {curr │
│ entSeason.season_name || `Season ${currentSe │
│ asonKey}`}</h2> │
│ - </header> │
│ - <div class="home__dungeon-gri │
│ d"> │
│ - {dungeonEntries.map(([slug, │
│ runs]) => ( │
│ - <DungeonRecordCard │
│ - dungeonName={runs[0].du │
│ ngeon_name} │
│ - dungeonSlug={slug} │
│ - runs={runs} │
│ - seasonId={parseInt(curr │
│ entSeasonKey)} │
│ + {seasons.map((s) => ( │
│ + <div │
│ + class:list={["home__season-pa │
│ nel", { hidden: s.key !== defaultSeasonKey } │
│ ]} │
│ + data-season-panel={s.key} │
│ + aria-hidden={s.key !== defaul │
│ tSeasonKey ? "true" : "false"} │
│ + > │
│ + <section class="home__section │
│ "> │
│ + <header class="home__sectio │
│ n-header"> │
│ + <h2>Top Players - {s.labe │
│ l}</h2> │
│ + </header> │
│ + <Carousel │
│ + ariaLabel={`Top players b │
│ y region - ${s.label}`} │
│ + class="home__players-caro │
│ usel" │
│ + perView={3} │
│ + perViewMobile={1} │
│ + > │
│ + <TopPlayersList │
│ + title="Global" │
│ + scope="global" │
│ + players={s.data.top_pla │
│ yers.global} │
│ + viewAllHref={`/challeng │
│ e-mode/season${s.key}/players/global`} │
│ /> │
│ - ))} │
│ - </div> │
│ - </section> │
│ - )} │
│ + <TopPlayersList │
│ + title="US" │
│ + scope="regional" │
│ + players={s.data.top_pla │
│ yers.us} │
│ + viewAllHref={`/challeng │
│ e-mode/season${s.key}/players/us`} │
│ + /> │
│ + <TopPlayersList │
│ + title="EU" │
│ + scope="regional" │
│ + players={s.data.top_pla │
│ yers.eu} │
│ + viewAllHref={`/challeng │
│ e-mode/season${s.key}/players/eu`} │
│ + /> │
│ + <TopPlayersList │
│ + title="KR" │
│ + scope="regional" │
│ + players={s.data.top_pla │
│ yers.kr} │
│ + viewAllHref={`/challeng │
│ e-mode/season${s.key}/players/kr`} │
│ + /> │
│ + <TopPlayersList │
│ + title="TW" │
│ + scope="regional" │
│ + players={s.data.top_pla │
│ yers.tw} │
│ + viewAllHref={`/challeng │
│ e-mode/season${s.key}/players/tw`} │
│ + /> │
│ + </Carousel> │
│ + </section> │
│ + │
│ + <section class="home__section │
│ "> │
│ + <header class="home__sectio │
│ n-header"> │
│ + <h2>Dungeon Records - {s. │
│ label}</h2> │
│ + </header> │
│ + <Carousel │
│ + ariaLabel={`Dungeon recor │
│ ds - ${s.label}`} │
│ + class="home__dungeons-car │
│ ousel" │
│ + perView={2} │
│ + perViewMobile={1} │
│ + > │
│ + {s.dungeonEntries.map(([s │
│ lug, runs]) => ( │
│ + <DungeonRecordCard │
│ + dungeonName={runs[0]. │
│ dungeon_name} │
│ + dungeonSlug={slug} │
│ + runs={runs} │
│ + seasonId={parseInt(s. │
│ key)} │
│ + /> │
│ + ))} │
│ + </Carousel> │
│ + </section> │
│ + </div> │
│ + ))} │
│ │
│ <section class="home__section"> │
│ <RecentRuns runs={recentRuns} / │
│ > │
│ @@ -135,6 +173,37 @@ const generatedAt = hom │
│ e?.generated_at ? new Date(home.generated_at │
│ ) : null; │
│ </div> │
│ </PageLayout> │
│ │
│ +<script> │
│ + // Season tabs toggle which season-panel │
│ is visible. Both panels are │
│ + // server-rendered so the switch is insta │
│ nt - no fetch, no re-render. │
│ + function wireSeasonTabs() { │
│ + const tabs = document.querySelectorAll< │
│ HTMLButtonElement>(".home__season-tab"); │
│ + const panels = document.querySelectorAl │
│ l<HTMLElement>(".home__season-panel"); │
│ + if (tabs.length === 0) return; │
│ + tabs.forEach((tab) => { │
│ + tab.addEventListener("click", () => { │
│ + const key = tab.dataset.seasonKey; │
│ + if (!key) return; │
│ + tabs.forEach((t) => { │
│ + const active = t === tab; │
│ + t.classList.toggle("active", acti │
│ ve); │
│ + t.setAttribute("aria-selected", a │
│ ctive ? "true" : "false"); │
│ + }); │
│ + panels.forEach((p) => { │
│ + const isActive = p.dataset.season │
│ Panel === key; │
│ + p.classList.toggle("hidden", !isA │
│ ctive); │
│ + p.setAttribute("aria-hidden", isA │
│ ctive ? "false" : "true"); │
│ + }); │
│ + // Carousel arrows depend on viewpo │
│ rt scrollWidth, which is 0 while the │
│ + // panel is hidden - dispatch resiz │
│ e so they re-evaluate after reveal. │
│ + window.dispatchEvent(new Event("res │
│ ize")); │
│ + }); │
│ + }); │
│ + } │
│ + wireSeasonTabs(); │
│ + document.addEventListener("astro:page-loa │
│ d", wireSeasonTabs); │
│ +</script> │
│ + │
│ <style lang="scss"> │
│ @use "../styles/core/tokens" as *; │
│ │
│ @@ -186,12 +255,61 @@ const generatedAt = ho │
│ me?.generated_at ? new Date(home.generated_a │
│ t) : null; │
│ font-size: $font-size-sm; │
│ } │
│ │
│ + // Season toggle - pill-style tabs matchi │
│ ng the stats page region tabs so the │
│ + // page-level filter feels consistent acr │
│ oss the site. │
│ + .home__season-tabs { │
│ + display: flex; │
│ + flex-wrap: wrap; │
│ + gap: $spacing-xs; │
│ + margin-bottom: $spacing-lg; │
│ + } │
│ + │
│ + .home__season-tab { │
│ + background: var(--bg-secondary); │
│ + border: 1px solid var(--border-color); │
│ + color: var(--text-secondary); │
│ + padding: 6px 14px; │
│ + cursor: pointer; │
│ + font-size: $font-size-sm; │
│ + font-weight: $font-weight-semibold; │
│ + border-radius: $radius-md; │
│ + transition: │
│ + color 0.15s ease, │
│ + background 0.15s ease, │
│ + border-color 0.15s ease; │
│ + │
│ + &:hover { │
│ + color: var(--text-primary); │
│ + border-color: var(--highlight-color); │
│ + } │
│ + │
│ + &.active { │
│ + color: var(--bg-primary); │
│ + background: var(--highlight-color); │
│ + border-color: var(--highlight-color); │
│ + } │
│ + } │
│ + │
│ + .home__season-panel { │
│ + display: flex; │
│ + flex-direction: column; │
│ + gap: $spacing-2xl; │
│ + │
│ + &.hidden { │
│ + display: none; │
│ + } │
│ + } │
│ + │
│ .home__section { │
│ display: flex; │
│ flex-direction: column; │
│ gap: $spacing-lg; │
│ │
│ - & + & { │
│ + & + &, │
│ + // The Recent Runs section sits as a si │
│ bling of the season-panel wrapper, │
│ + // not another `.home__section`, so the │
│ adjacency selector above wouldn't │
│ + // reach it without this second rule. K │
│ eeps section rhythm consistent. │
│ + .home__season-panel + & { │
│ margin-top: $spacing-2xl; │
│ } │
│ } │
│ @@ -205,35 +323,8 @@ const generatedAt = hom │
│ e?.generated_at ? new Date(home.generated_at │
│ ) : null; │
│ } │
│ } │
│ │
│ - // Flexbox (not grid) so an incomplete la │
│ st row centers horizontally. │
│ - // The `:global()` is needed because Astr │
│ o scopes `> *` to local elements, │
│ - // but the children are component roots f │
│ rom DungeonRecordCard / TopPlayersList. │
│ - .home__dungeon-grid { │
│ - display: flex; │
│ - flex-wrap: wrap; │
│ - justify-content: center; │
│ - gap: $spacing-md $spacing-lg; │
│ - } │
│ - │
│ - .home__dungeon-grid > :global(.dungeon-re │
│ cord-card) { │
│ - flex: 1 1 calc(50% - #{$spacing-lg * 0. │
│ 5}); │
│ - max-width: calc(50% - #{$spacing-lg * 0 │
│ .5}); │
│ - min-width: 0; │
│ - } │
│ - │
│ - .home__players-grid { │
│ - display: flex; │
│ - flex-wrap: wrap; │
│ - justify-content: center; │
│ - gap: $spacing-lg; │
│ - } │
│ - │
│ - .home__players-grid > :global(.top-player │
│ s-list) { │
│ - // 1px fudge to avoid sub-pixel roundin │
│ g pushing the 3rd item to a new row │
│ - flex: 1 1 calc(33.333% - #{$spacing-lg │
│ * 2 * 0.333} - 1px); │
│ - max-width: calc(33.333% - #{$spacing-lg │
│ * 2 * 0.333} - 1px); │
│ - min-width: 280px; │
│ - } │
│ + // Carousel handles per-card sizing via i │
│ ts `perView` prop (see Carousel.scss). │
│ + // No per-card overrides needed here. │
│ │
│ // Mobile overrides - placed at the end o │
│ f the stylesheet so they win the │
│ // cascade over the desktop rules above ( │
│ same selector specificity). │
│ @@ -246,21 +337,5 @@ const generatedAt = hom │
│ e?.generated_at ? new Date(home.generated_at │
│ ) : null; │
│ .home__hero h1 { │
│ font-size: 1.5em; │
│ } │
│ - │
│ - // Stack dungeon cards full-width on mo │
│ bile so TeamComposition's vertical │
│ - // member stack (its own ~720px breakpo │
│ int) has room to read; the 2-col 165px │
│ - // grid otherwise overlapped player nam │
│ es with time/date. │
│ - .home__dungeon-grid > :global(.dungeon- │
│ record-card) { │
│ - flex: 1 1 100%; │
│ - max-width: 100%; │
│ - } │
│ - │
│ - // Player lists also fill the row; othe │
│ rwise the 280px min-width caps each │
│ - // list below the available content are │
│ a, leaving wasted right-margin. │
│ - .home__players-grid > :global(.top-play │
│ ers-list) { │
│ - flex: 1 1 100%; │
│ - max-width: 100%; │
│ - min-width: 0; │
│ - } │
│ } │
│ </style> │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET