OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
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
 

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>

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>
 

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;
+    }
+  }
+}

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;
+    }
+  }
+}
 

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);
+  });
+}

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);
+  });
+}
 

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>

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