OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      8eeedb695220
DATE      2026-05-03
SUBJECT   web: add challenge mode home page
FILES     14 CHANGED
HASH      8eeedb695220
DATE      2026-05-03
SUBJECT   web: add challenge mode home page
FILES     14 CHANGED
 

diff --git a/web/src/components/Home/ChallengeRun/ChallengeRun.astro b/web/src/com
ponents/Home/ChallengeRun/ChallengeRun.astro
new file mode 100644
index 0000000..af2a617
--- /dev/null
+++ b/web/src/components/Home/ChallengeRun/ChallengeRun.astro
@@ -0,0 +1,74 @@
+---
+import "./ChallengeRun.scss";
+import { formatDurationMMSS, formatShortDate } from "../../../lib/utils";
+import { getDungeonIconUrl } from "../../../lib/dungeonIcons";
+import TeamComposition from "../../Leaderboard/TeamComposition/TeamComposition.as
tro";
+import type { TeamMember } from "../../../lib/types";
+
+interface Props {
+  dungeonName: string;
+  dungeonSlug: string;
+  durationMs: number;
+  completedTimestamp: number;
+  teamMembers: TeamMember[];
+  showDungeonIcon?: boolean;
+  showDungeonName?: boolean;
+  rank?: number;
+  bracket?: string;
+}
+
+const {
+  dungeonName,
+  dungeonSlug,
+  durationMs,
+  completedTimestamp,
+  teamMembers,
+  showDungeonIcon = true,
+  showDungeonName = true,
+  rank,
+  bracket,
+} = Astro.props;
+
+const dungeonIcon = getDungeonIconUrl(dungeonSlug, dungeonName);
+const bracketClass = bracket ? `bracket-${bracket}` : "";
+---
+
+<article class="challenge-run">
+  {
+    showDungeonIcon && dungeonIcon && (
+      <a
+        class="challenge-run__dungeon"
+        href={`/challenge-mode/season2/global/${dungeonSlug}`}
+        title={dungeonName}
+      >
+        <img src={dungeonIcon} alt={dungeonName} loading="lazy" />
+      </a>
+    )
+  }
+
+  {
+    rank && (
+      <span class:list={["challenge-run__rank", "ranking-number", bracketClass]}>
{rank}</span>
+    )
+  }
+
+  {
+    showDungeonName && (
+      <span class="challenge-run__dungeon-name">{dungeonName}</span>
+    )
+  }
+
+  <div class="challenge-run__team">
+    <TeamComposition members={teamMembers} />
+  </div>
+
+  <span class="challenge-run__duration">{formatDurationMMSS(durationMs)}</span>
+
+  <time
+    class="challenge-run__date"
+    datetime={new Date(completedTimestamp).toISOString()}
+    title={new Date(completedTimestamp).toLocaleString()}
+  >
+    {formatShortDate(completedTimestamp)}
+  </time>
+</article>

diff --git a/web/src/components/Home/Challen
geRun/ChallengeRun.astro b/web/src/component
s/Home/ChallengeRun/ChallengeRun.astro
new file mode 100644
index 0000000..af2a617
--- /dev/null
+++ b/web/src/components/Home/ChallengeRun/C
hallengeRun.astro
@@ -0,0 +1,74 @@
+---
+import "./ChallengeRun.scss";
+import { formatDurationMMSS, formatShortDat
e } from "../../../lib/utils";
+import { getDungeonIconUrl } from "../../..
/lib/dungeonIcons";
+import TeamComposition from "../../Leaderbo
ard/TeamComposition/TeamComposition.astro";
+import type { TeamMember } from "../../../l
ib/types";
+
+interface Props {
+  dungeonName: string;
+  dungeonSlug: string;
+  durationMs: number;
+  completedTimestamp: number;
+  teamMembers: TeamMember[];
+  showDungeonIcon?: boolean;
+  showDungeonName?: boolean;
+  rank?: number;
+  bracket?: string;
+}
+
+const {
+  dungeonName,
+  dungeonSlug,
+  durationMs,
+  completedTimestamp,
+  teamMembers,
+  showDungeonIcon = true,
+  showDungeonName = true,
+  rank,
+  bracket,
+} = Astro.props;
+
+const dungeonIcon = getDungeonIconUrl(dunge
onSlug, dungeonName);
+const bracketClass = bracket ? `bracket-${b
racket}` : "";
+---
+
+<article class="challenge-run">
+  {
+    showDungeonIcon && dungeonIcon && (
+      <a
+        class="challenge-run__dungeon"
+        href={`/challenge-mode/season2/glob
al/${dungeonSlug}`}
+        title={dungeonName}
+      >
+        <img src={dungeonIcon} alt={dungeon
Name} loading="lazy" />
+      </a>
+    )
+  }
+
+  {
+    rank && (
+      <span class:list={["challenge-run__ra
nk", "ranking-number", bracketClass]}>{rank}
</span>
+    )
+  }
+
+  {
+    showDungeonName && (
+      <span class="challenge-run__dungeon-n
ame">{dungeonName}</span>
+    )
+  }
+
+  <div class="challenge-run__team">
+    <TeamComposition members={teamMembers} 
/>
+  </div>
+
+  <span class="challenge-run__duration">{fo
rmatDurationMMSS(durationMs)}</span>
+
+  <time
+    class="challenge-run__date"
+    datetime={new Date(completedTimestamp).
toISOString()}
+    title={new Date(completedTimestamp).toL
ocaleString()}
+  >
+    {formatShortDate(completedTimestamp)}
+  </time>
+</article>
 

diff --git a/web/src/components/Home/ChallengeRun/ChallengeRun.scss b/web/src/comp
onents/Home/ChallengeRun/ChallengeRun.scss
new file mode 100644
index 0000000..0ad9de2
--- /dev/null
+++ b/web/src/components/Home/ChallengeRun/ChallengeRun.scss
@@ -0,0 +1,94 @@
+@use "../../../styles/core/tokens" as *;
+
+.challenge-run {
+  display: flex;
+  align-items: center;
+  gap: $spacing-sm;
+  padding: $spacing-xs $spacing-sm;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border-color);
+  border-radius: $radius-sm;
+  font-size: $font-size-sm;
+
+  &__dungeon {
+    flex: 0 0 auto;
+    display: block;
+    width: 28px;
+    height: 28px;
+    border-radius: $radius-sm;
+    overflow: hidden;
+    border: 1px solid var(--border-color);
+
+    img {
+      width: 100%;
+      height: 100%;
+      display: block;
+    }
+
+    &:hover {
+      border-color: var(--highlight-color);
+    }
+  }
+
+  &__rank {
+    flex: 0 0 auto;
+    font-weight: $font-weight-bold;
+    font-size: $font-size-base;
+    min-width: 2.2em;
+    text-align: center;
+    line-height: 1.4;
+    // colors come from .ranking-number .bracket-{name} (global ranking-utils con
vention)
+  }
+
+  &__dungeon-name {
+    flex: 0 0 auto;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 18ch;
+  }
+
+  &__team {
+    flex: 1 1 auto;
+    min-width: 0;
+
+    // Tighten TeamComposition's default 12px gap for the dense home page rows.
+    // Scoped override; doesn't affect leaderboard pages where 12px is appropriat
e.
+    .team-composition {
+      gap: 4px $spacing-sm;
+
+      // TeamComposition's stock mobile rule (?720px) makes each .team-member
+      // full-width with a dark bubble background - designed for leaderboard
+      // table rows, not our flex-row ChallengeRun. The full-width bubbles
+      // collide with the rank/time/date positioned next to the team. Override
+      // back to inline rendering for the home page context.
+      @media (max-width: 720px) {
+        flex-direction: row;
+
+        .team-member {
+          width: auto;
+          background: transparent;
+          padding: 0;
+          border-radius: 0;
+        }
+      }
+    }
+  }
+
+  &__duration {
+    flex: 0 0 auto;
+    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    font-size: $font-size-base;
+  }
+
+  &__date {
+    flex: 0 0 auto;
+    color: var(--text-tertiary);
+    font-size: $font-size-xs;
+    white-space: nowrap;
+  }
+}

diff --git a/web/src/components/Home/Challen
geRun/ChallengeRun.scss b/web/src/components
/Home/ChallengeRun/ChallengeRun.scss
new file mode 100644
index 0000000..0ad9de2
--- /dev/null
+++ b/web/src/components/Home/ChallengeRun/C
hallengeRun.scss
@@ -0,0 +1,94 @@
+@use "../../../styles/core/tokens" as *;
+
+.challenge-run {
+  display: flex;
+  align-items: center;
+  gap: $spacing-sm;
+  padding: $spacing-xs $spacing-sm;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border-color);
+  border-radius: $radius-sm;
+  font-size: $font-size-sm;
+
+  &__dungeon {
+    flex: 0 0 auto;
+    display: block;
+    width: 28px;
+    height: 28px;
+    border-radius: $radius-sm;
+    overflow: hidden;
+    border: 1px solid var(--border-color);
+
+    img {
+      width: 100%;
+      height: 100%;
+      display: block;
+    }
+
+    &:hover {
+      border-color: var(--highlight-color);
+    }
+  }
+
+  &__rank {
+    flex: 0 0 auto;
+    font-weight: $font-weight-bold;
+    font-size: $font-size-base;
+    min-width: 2.2em;
+    text-align: center;
+    line-height: 1.4;
+    // colors come from .ranking-number .br
acket-{name} (global ranking-utils conventio
n)
+  }
+
+  &__dungeon-name {
+    flex: 0 0 auto;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 18ch;
+  }
+
+  &__team {
+    flex: 1 1 auto;
+    min-width: 0;
+
+    // Tighten TeamComposition's default 12
px gap for the dense home page rows.
+    // Scoped override; doesn't affect lead
erboard pages where 12px is appropriate.
+    .team-composition {
+      gap: 4px $spacing-sm;
+
+      // TeamComposition's stock mobile rul
e (?720px) makes each .team-member
+      // full-width with a dark bubble back
ground - designed for leaderboard
+      // table rows, not our flex-row Chall
engeRun. The full-width bubbles
+      // collide with the rank/time/date po
sitioned next to the team. Override
+      // back to inline rendering for the h
ome page context.
+      @media (max-width: 720px) {
+        flex-direction: row;
+
+        .team-member {
+          width: auto;
+          background: transparent;
+          padding: 0;
+          border-radius: 0;
+        }
+      }
+    }
+  }
+
+  &__duration {
+    flex: 0 0 auto;
+    font-family: ui-monospace, SFMono-Regul
ar, Menlo, monospace;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    font-size: $font-size-base;
+  }
+
+  &__date {
+    flex: 0 0 auto;
+    color: var(--text-tertiary);
+    font-size: $font-size-xs;
+    white-space: nowrap;
+  }
+}
 

diff --git a/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.astro b/w
eb/src/components/Home/DungeonRecordCard/DungeonRecordCard.astro
new file mode 100644
index 0000000..eb3c166
--- /dev/null
+++ b/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.astro
@@ -0,0 +1,56 @@
+---
+import "./DungeonRecordCard.scss";
+import ChallengeRun from "../ChallengeRun/ChallengeRun.astro";
+import { getDungeonIconUrl } from "../../../lib/dungeonIcons";
+import type { HomeRunEntry } from "../../../lib/types";
+
+interface Props {
+  dungeonName: string;
+  dungeonSlug: string;
+  runs: HomeRunEntry[];
+  seasonId: number;
+}
+
+const { dungeonName, dungeonSlug, runs, seasonId } = Astro.props;
+const dungeonIcon = getDungeonIconUrl(dungeonSlug, dungeonName);
+---
+
+<section class="dungeon-record-card">
+  <header class="dungeon-record-card__header">
+    {
+      dungeonIcon && (
+        <img
+          class="dungeon-record-card__icon"
+          src={dungeonIcon}
+          alt={dungeonName}
+          loading="lazy"
+        />
+      )
+    }
+    <h3 class="dungeon-record-card__title">{dungeonName}</h3>
+    <a
+      class="dungeon-record-card__view-all"
+      href={`/challenge-mode/season${seasonId}/global/${dungeonSlug}`}
+    >
+      View ?
+    </a>
+  </header>
+
+  <div class="dungeon-record-card__runs">
+    {
+      runs.map((r, i) => (
+        <ChallengeRun
+          dungeonName={r.dungeon_name}
+          dungeonSlug={r.dungeon_slug}
+          durationMs={r.duration_ms}
+          completedTimestamp={r.completed_timestamp}
+          teamMembers={r.team_members}
+          showDungeonIcon={false}
+          showDungeonName={false}
+          rank={r.rank ?? i + 1}
+          bracket={r.bracket}
+        />
+      ))
+    }
+  </div>
+</section>

diff --git a/web/src/components/Home/Dungeon
RecordCard/DungeonRecordCard.astro b/web/src
/components/Home/DungeonRecordCard/DungeonRe
cordCard.astro
new file mode 100644
index 0000000..eb3c166
--- /dev/null
+++ b/web/src/components/Home/DungeonRecordC
ard/DungeonRecordCard.astro
@@ -0,0 +1,56 @@
+---
+import "./DungeonRecordCard.scss";
+import ChallengeRun from "../ChallengeRun/C
hallengeRun.astro";
+import { getDungeonIconUrl } from "../../..
/lib/dungeonIcons";
+import type { HomeRunEntry } from "../../..
/lib/types";
+
+interface Props {
+  dungeonName: string;
+  dungeonSlug: string;
+  runs: HomeRunEntry[];
+  seasonId: number;
+}
+
+const { dungeonName, dungeonSlug, runs, sea
sonId } = Astro.props;
+const dungeonIcon = getDungeonIconUrl(dunge
onSlug, dungeonName);
+---
+
+<section class="dungeon-record-card">
+  <header class="dungeon-record-card__heade
r">
+    {
+      dungeonIcon && (
+        <img
+          class="dungeon-record-card__icon"
+          src={dungeonIcon}
+          alt={dungeonName}
+          loading="lazy"
+        />
+      )
+    }
+    <h3 class="dungeon-record-card__title">
{dungeonName}</h3>
+    <a
+      class="dungeon-record-card__view-all"
+      href={`/challenge-mode/season${season
Id}/global/${dungeonSlug}`}
+    >
+      View ?
+    </a>
+  </header>
+
+  <div class="dungeon-record-card__runs">
+    {
+      runs.map((r, i) => (
+        <ChallengeRun
+          dungeonName={r.dungeon_name}
+          dungeonSlug={r.dungeon_slug}
+          durationMs={r.duration_ms}
+          completedTimestamp={r.completed_t
imestamp}
+          teamMembers={r.team_members}
+          showDungeonIcon={false}
+          showDungeonName={false}
+          rank={r.rank ?? i + 1}
+          bracket={r.bracket}
+        />
+      ))
+    }
+  </div>
+</section>
 

diff --git a/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.scss b/we
b/src/components/Home/DungeonRecordCard/DungeonRecordCard.scss
new file mode 100644
index 0000000..b94d8cd
--- /dev/null
+++ b/web/src/components/Home/DungeonRecordCard/DungeonRecordCard.scss
@@ -0,0 +1,60 @@
+@use "../../../styles/core/tokens" as *;
+
+// No outer border / background - the page bg shows through. Matches the visual
+// pattern used by TopPlayersList and RecentRuns (header + rows; no card chrome).
+.dungeon-record-card {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-sm;
+
+  &__header {
+    display: flex;
+    align-items: center;
+    gap: $spacing-sm;
+    padding-bottom: $spacing-xs;
+    border-bottom: 1px solid var(--border-color);
+  }
+
+  &__icon {
+    width: 28px;
+    height: 28px;
+    border-radius: $radius-sm;
+    border: 1px solid var(--border-color);
+    display: block;
+  }
+
+  &__title {
+    margin: 0;
+    flex: 1;
+    font-size: $font-size-md;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  &__view-all {
+    color: var(--text-tertiary);
+    text-decoration: none;
+    font-size: $font-size-xs;
+    transition: color 0.15s ease;
+    white-space: nowrap;
+
+    &:hover {
+      color: var(--highlight-color);
+    }
+  }
+
+  &__runs {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+
+    // Most rows fit on one line at 2-col grid widths; a few still wrap when name
s
+    // are long. Sized to the natural wrap so 1-line rows pad up to match.
+    .challenge-run {
+      min-height: 54px;
+    }
+  }
+}

diff --git a/web/src/components/Home/Dungeon
RecordCard/DungeonRecordCard.scss b/web/src/
components/Home/DungeonRecordCard/DungeonRec
ordCard.scss
new file mode 100644
index 0000000..b94d8cd
--- /dev/null
+++ b/web/src/components/Home/DungeonRecordC
ard/DungeonRecordCard.scss
@@ -0,0 +1,60 @@
+@use "../../../styles/core/tokens" as *;
+
+// No outer border / background - the page 
bg shows through. Matches the visual
+// pattern used by TopPlayersList and Recen
tRuns (header + rows; no card chrome).
+.dungeon-record-card {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-sm;
+
+  &__header {
+    display: flex;
+    align-items: center;
+    gap: $spacing-sm;
+    padding-bottom: $spacing-xs;
+    border-bottom: 1px solid var(--border-c
olor);
+  }
+
+  &__icon {
+    width: 28px;
+    height: 28px;
+    border-radius: $radius-sm;
+    border: 1px solid var(--border-color);
+    display: block;
+  }
+
+  &__title {
+    margin: 0;
+    flex: 1;
+    font-size: $font-size-md;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  &__view-all {
+    color: var(--text-tertiary);
+    text-decoration: none;
+    font-size: $font-size-xs;
+    transition: color 0.15s ease;
+    white-space: nowrap;
+
+    &:hover {
+      color: var(--highlight-color);
+    }
+  }
+
+  &__runs {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+
+    // Most rows fit on one line at 2-col g
rid widths; a few still wrap when names
+    // are long. Sized to the natural wrap 
so 1-line rows pad up to match.
+    .challenge-run {
+      min-height: 54px;
+    }
+  }
+}
 

diff --git a/web/src/components/Home/PlayerRankRow/PlayerRankRow.astro b/web/src/c
omponents/Home/PlayerRankRow/PlayerRankRow.astro
new file mode 100644
index 0000000..eebd200
--- /dev/null
+++ b/web/src/components/Home/PlayerRankRow/PlayerRankRow.astro
@@ -0,0 +1,81 @@
+---
+import "./PlayerRankRow.scss";
+import { formatDurationMMSS } from "../../../lib/utils";
+import {
+  getSpecInfo,
+  getSpecIcon,
+  getClassTextClass,
+} from "../../../lib/wow-constants";
+import { buildPlayerProfileURL } from "../../Leaderboard/PlayerLink/PlayerLink.ts
";
+
+interface Props {
+  rank: number;
+  bracket?: string;
+  name: string;
+  realmSlug: string;
+  realmName?: string;
+  region: string;
+  className?: string;
+  activeSpecId?: number;
+  combinedBestTimeMs: number;
+  avatarUrl?: string;
+}
+
+const {
+  rank,
+  bracket,
+  name,
+  realmSlug,
+  realmName,
+  region,
+  activeSpecId,
+  combinedBestTimeMs,
+  avatarUrl,
+} = Astro.props;
+
+const bracketClass = bracket ? `bracket-${bracket}` : "";
+const profileUrl = buildPlayerProfileURL(region, realmSlug, name);
+const specInfo = activeSpecId ? getSpecInfo(activeSpecId) : null;
+const specIconUrl = specInfo ? getSpecIcon(specInfo.class, specInfo.spec) : null;
+// Reuse the global .text-{class} utility (matches PlayerLink + WoWClassColors)
+const classColorClass = specInfo ? getClassTextClass(specInfo.class) : "";
+---
+
+<a class="player-rank-row" href={profileUrl}>
+  <span class:list={["player-rank-row__rank", "ranking-number", bracketClass]}>{r
ank}</span>
+
+  {
+    avatarUrl ? (
+      <img
+        class="player-rank-row__avatar"
+        src={avatarUrl}
+        alt={`${name} avatar`}
+        loading="lazy"
+      />
+    ) : (
+      <div class="player-rank-row__avatar player-rank-row__avatar--placeholder" /
>
+    )
+  }
+
+  <div class="player-rank-row__info">
+    <span class="player-rank-row__name-line">
+      {
+        specIconUrl && (
+          <img
+            class="spec-icon"
+            src={specIconUrl}
+            alt={specInfo?.spec || ""}
+            loading="lazy"
+          />
+        )
+      }
+      <span class:list={["player-link-name", classColorClass]}>{name}</span>
+    </span>
+    <span class="player-rank-row__realm">
+      {realmName || realmSlug}
+      <span class="player-rank-row__region">{region.toUpperCase()}</span>
+    </span>
+  </div>
+
+  <span class="player-rank-row__time">{formatDurationMMSS(combinedBestTimeMs)}</s
pan>
+</a>

diff --git a/web/src/components/Home/PlayerR
ankRow/PlayerRankRow.astro b/web/src/compone
nts/Home/PlayerRankRow/PlayerRankRow.astro
new file mode 100644
index 0000000..eebd200
--- /dev/null
+++ b/web/src/components/Home/PlayerRankRow/
PlayerRankRow.astro
@@ -0,0 +1,81 @@
+---
+import "./PlayerRankRow.scss";
+import { formatDurationMMSS } from "../../.
./lib/utils";
+import {
+  getSpecInfo,
+  getSpecIcon,
+  getClassTextClass,
+} from "../../../lib/wow-constants";
+import { buildPlayerProfileURL } from "../.
./Leaderboard/PlayerLink/PlayerLink.ts";
+
+interface Props {
+  rank: number;
+  bracket?: string;
+  name: string;
+  realmSlug: string;
+  realmName?: string;
+  region: string;
+  className?: string;
+  activeSpecId?: number;
+  combinedBestTimeMs: number;
+  avatarUrl?: string;
+}
+
+const {
+  rank,
+  bracket,
+  name,
+  realmSlug,
+  realmName,
+  region,
+  activeSpecId,
+  combinedBestTimeMs,
+  avatarUrl,
+} = Astro.props;
+
+const bracketClass = bracket ? `bracket-${b
racket}` : "";
+const profileUrl = buildPlayerProfileURL(re
gion, realmSlug, name);
+const specInfo = activeSpecId ? getSpecInfo
(activeSpecId) : null;
+const specIconUrl = specInfo ? getSpecIcon(
specInfo.class, specInfo.spec) : null;
+// Reuse the global .text-{class} utility (
matches PlayerLink + WoWClassColors)
+const classColorClass = specInfo ? getClass
TextClass(specInfo.class) : "";
+---
+
+<a class="player-rank-row" href={profileUrl
}>
+  <span class:list={["player-rank-row__rank
", "ranking-number", bracketClass]}>{rank}</
span>
+
+  {
+    avatarUrl ? (
+      <img
+        class="player-rank-row__avatar"
+        src={avatarUrl}
+        alt={`${name} avatar`}
+        loading="lazy"
+      />
+    ) : (
+      <div class="player-rank-row__avatar p
layer-rank-row__avatar--placeholder" />
+    )
+  }
+
+  <div class="player-rank-row__info">
+    <span class="player-rank-row__name-line
">
+      {
+        specIconUrl && (
+          <img
+            class="spec-icon"
+            src={specIconUrl}
+            alt={specInfo?.spec || ""}
+            loading="lazy"
+          />
+        )
+      }
+      <span class:list={["player-link-name"
, classColorClass]}>{name}</span>
+    </span>
+    <span class="player-rank-row__realm">
+      {realmName || realmSlug}
+      <span class="player-rank-row__region"
>{region.toUpperCase()}</span>
+    </span>
+  </div>
+
+  <span class="player-rank-row__time">{form
atDurationMMSS(combinedBestTimeMs)}</span>
+</a>
 

diff --git a/web/src/components/Home/PlayerRankRow/PlayerRankRow.scss b/web/src/co
mponents/Home/PlayerRankRow/PlayerRankRow.scss
new file mode 100644
index 0000000..89258af
--- /dev/null
+++ b/web/src/components/Home/PlayerRankRow/PlayerRankRow.scss
@@ -0,0 +1,83 @@
+@use "../../../styles/core/tokens" as *;
+
+.player-rank-row {
+  display: grid;
+  grid-template-columns: 48px 40px 1fr auto;
+  align-items: center;
+  gap: $spacing-md;
+  padding: $spacing-sm $spacing-md;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border-color);
+  border-radius: $radius-md;
+  text-decoration: none;
+  color: inherit;
+
+  &:hover {
+    border-color: var(--highlight-color);
+    text-decoration: none;
+  }
+
+  &__rank {
+    text-align: center;
+    min-width: 2.2em;
+    // colors from .ranking-number + .bracket-{name} (global ranking-utils conven
tion)
+  }
+
+  &__avatar {
+    width: 40px;
+    height: 40px;
+    border-radius: 50%;
+    object-fit: cover;
+    border: 1px solid var(--border-color);
+
+    &--placeholder {
+      background: var(--bg-primary);
+    }
+  }
+
+  &__info {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    gap: 2px;
+    overflow: hidden;
+  }
+
+  &__name-line {
+    display: inline-flex;
+    align-items: center;
+    gap: $spacing-xs;
+    min-width: 0;
+    overflow: hidden;
+
+    .player-link-name {
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
+  &__realm {
+    font-size: $font-size-xs;
+    color: var(--text-tertiary);
+    display: inline-flex;
+    gap: $spacing-xs;
+    align-items: center;
+  }
+
+  &__region {
+    background: var(--bg-primary);
+    border: 1px solid var(--border-color);
+    border-radius: $radius-sm;
+    padding: 0 4px;
+    font-size: 10px;
+    font-weight: $font-weight-bold;
+    color: var(--text-secondary);
+  }
+
+  &__time {
+    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    font-size: $font-size-md;
+  }
+}

diff --git a/web/src/components/Home/PlayerR
ankRow/PlayerRankRow.scss b/web/src/componen
ts/Home/PlayerRankRow/PlayerRankRow.scss
new file mode 100644
index 0000000..89258af
--- /dev/null
+++ b/web/src/components/Home/PlayerRankRow/
PlayerRankRow.scss
@@ -0,0 +1,83 @@
+@use "../../../styles/core/tokens" as *;
+
+.player-rank-row {
+  display: grid;
+  grid-template-columns: 48px 40px 1fr auto
;
+  align-items: center;
+  gap: $spacing-md;
+  padding: $spacing-sm $spacing-md;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border-color);
+  border-radius: $radius-md;
+  text-decoration: none;
+  color: inherit;
+
+  &:hover {
+    border-color: var(--highlight-color);
+    text-decoration: none;
+  }
+
+  &__rank {
+    text-align: center;
+    min-width: 2.2em;
+    // colors from .ranking-number + .brack
et-{name} (global ranking-utils convention)
+  }
+
+  &__avatar {
+    width: 40px;
+    height: 40px;
+    border-radius: 50%;
+    object-fit: cover;
+    border: 1px solid var(--border-color);
+
+    &--placeholder {
+      background: var(--bg-primary);
+    }
+  }
+
+  &__info {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    gap: 2px;
+    overflow: hidden;
+  }
+
+  &__name-line {
+    display: inline-flex;
+    align-items: center;
+    gap: $spacing-xs;
+    min-width: 0;
+    overflow: hidden;
+
+    .player-link-name {
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
+  &__realm {
+    font-size: $font-size-xs;
+    color: var(--text-tertiary);
+    display: inline-flex;
+    gap: $spacing-xs;
+    align-items: center;
+  }
+
+  &__region {
+    background: var(--bg-primary);
+    border: 1px solid var(--border-color);
+    border-radius: $radius-sm;
+    padding: 0 4px;
+    font-size: 10px;
+    font-weight: $font-weight-bold;
+    color: var(--text-secondary);
+  }
+
+  &__time {
+    font-family: ui-monospace, SFMono-Regul
ar, Menlo, monospace;
+    color: var(--text-primary);
+    font-weight: $font-weight-semibold;
+    font-size: $font-size-md;
+  }
+}
 

diff --git a/web/src/components/Home/RecentRuns/RecentRuns.astro b/web/src/compone
nts/Home/RecentRuns/RecentRuns.astro
new file mode 100644
index 0000000..2a55e88
--- /dev/null
+++ b/web/src/components/Home/RecentRuns/RecentRuns.astro
@@ -0,0 +1,39 @@
+---
+import "./RecentRuns.scss";
+import ChallengeRun from "../ChallengeRun/ChallengeRun.astro";
+import type { HomeRunEntry } from "../../../lib/types";
+
+interface Props {
+  runs: HomeRunEntry[];
+}
+
+const { runs } = Astro.props;
+---
+
+<section class="recent-runs">
+  <header class="recent-runs__header">
+    <h2>Recent Top Runs</h2>
+  </header>
+
+  {
+    runs.length === 0 ? (
+      <p class="recent-runs__empty">No recent top runs yet.</p>
+    ) : (
+      <div class="recent-runs__list">
+        {runs.map((r) => (
+          <ChallengeRun
+            dungeonName={r.dungeon_name}
+            dungeonSlug={r.dungeon_slug}
+            durationMs={r.duration_ms}
+            completedTimestamp={r.completed_timestamp}
+            teamMembers={r.team_members}
+            showDungeonIcon={true}
+            showDungeonName={false}
+            rank={r.rankings?.global}
+            bracket={r.bracket}
+          />
+        ))}
+      </div>
+    )
+  }
+</section>

diff --git a/web/src/components/Home/RecentR
uns/RecentRuns.astro b/web/src/components/Ho
me/RecentRuns/RecentRuns.astro
new file mode 100644
index 0000000..2a55e88
--- /dev/null
+++ b/web/src/components/Home/RecentRuns/Rec
entRuns.astro
@@ -0,0 +1,39 @@
+---
+import "./RecentRuns.scss";
+import ChallengeRun from "../ChallengeRun/C
hallengeRun.astro";
+import type { HomeRunEntry } from "../../..
/lib/types";
+
+interface Props {
+  runs: HomeRunEntry[];
+}
+
+const { runs } = Astro.props;
+---
+
+<section class="recent-runs">
+  <header class="recent-runs__header">
+    <h2>Recent Top Runs</h2>
+  </header>
+
+  {
+    runs.length === 0 ? (
+      <p class="recent-runs__empty">No rece
nt top runs yet.</p>
+    ) : (
+      <div class="recent-runs__list">
+        {runs.map((r) => (
+          <ChallengeRun
+            dungeonName={r.dungeon_name}
+            dungeonSlug={r.dungeon_slug}
+            durationMs={r.duration_ms}
+            completedTimestamp={r.completed
_timestamp}
+            teamMembers={r.team_members}
+            showDungeonIcon={true}
+            showDungeonName={false}
+            rank={r.rankings?.global}
+            bracket={r.bracket}
+          />
+        ))}
+      </div>
+    )
+  }
+</section>
 

diff --git a/web/src/components/Home/RecentRuns/RecentRuns.scss b/web/src/componen
ts/Home/RecentRuns/RecentRuns.scss
new file mode 100644
index 0000000..bbb9550
--- /dev/null
+++ b/web/src/components/Home/RecentRuns/RecentRuns.scss
@@ -0,0 +1,39 @@
+@use "../../../styles/core/tokens" as *;
+
+.recent-runs {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-lg;
+
+  &__header {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: $spacing-md;
+
+    h2 {
+      margin: 0;
+      font-size: $font-size-2xl;
+      color: var(--text-primary);
+      font-weight: $font-weight-semibold;
+    }
+  }
+
+  &__subtitle {
+    margin: $spacing-xs 0 0;
+    color: var(--text-secondary);
+    font-size: $font-size-sm;
+  }
+
+  &__empty {
+    color: var(--text-tertiary);
+    text-align: center;
+    padding: $spacing-2xl;
+  }
+
+  &__list {
+    display: flex;
+    flex-direction: column;
+    gap: $spacing-sm;
+  }
+}

diff --git a/web/src/components/Home/RecentR
uns/RecentRuns.scss b/web/src/components/Hom
e/RecentRuns/RecentRuns.scss
new file mode 100644
index 0000000..bbb9550
--- /dev/null
+++ b/web/src/components/Home/RecentRuns/Rec
entRuns.scss
@@ -0,0 +1,39 @@
+@use "../../../styles/core/tokens" as *;
+
+.recent-runs {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-lg;
+
+  &__header {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: $spacing-md;
+
+    h2 {
+      margin: 0;
+      font-size: $font-size-2xl;
+      color: var(--text-primary);
+      font-weight: $font-weight-semibold;
+    }
+  }
+
+  &__subtitle {
+    margin: $spacing-xs 0 0;
+    color: var(--text-secondary);
+    font-size: $font-size-sm;
+  }
+
+  &__empty {
+    color: var(--text-tertiary);
+    text-align: center;
+    padding: $spacing-2xl;
+  }
+
+  &__list {
+    display: flex;
+    flex-direction: column;
+    gap: $spacing-sm;
+  }
+}
 

diff --git a/web/src/components/Home/TopPlayersList/TopPlayersList.astro b/web/src
/components/Home/TopPlayersList/TopPlayersList.astro
new file mode 100644
index 0000000..821f7a2
--- /dev/null
+++ b/web/src/components/Home/TopPlayersList/TopPlayersList.astro
@@ -0,0 +1,55 @@
+---
+import "./TopPlayersList.scss";
+import PlayerRankRow from "../PlayerRankRow/PlayerRankRow.astro";
+import type { HomePlayerEntry } from "../../../lib/types";
+
+interface Props {
+  title: string;
+  players: HomePlayerEntry[];
+  scope: "global" | "regional";
+  viewAllHref?: string;
+}
+
+const { title, players, scope, viewAllHref } = Astro.props;
+
+// Pick the bracket that matches the scope of this list.
+// Global list shows global rank; regional list shows regional rank - color the b
adge accordingly.
+function bracketFor(p: HomePlayerEntry): string | undefined {
+  return scope === "global" ? p.global_ranking_bracket : p.regional_ranking_brack
et;
+}
+---
+
+<section class="top-players-list">
+  <header class="top-players-list__header">
+    <h3>{title}</h3>
+    {
+      viewAllHref && (
+        <a class="top-players-list__view-all" href={viewAllHref}>
+          View all ?
+        </a>
+      )
+    }
+  </header>
+
+  {
+    players.length === 0 ? (
+      <p class="top-players-list__empty">No ranked players in this scope yet.</p>
+    ) : (
+      <div class="top-players-list__rows">
+        {players.map((p) => (
+          <PlayerRankRow
+            rank={p.rank}
+            bracket={bracketFor(p)}
+            name={p.name}
+            realmSlug={p.realm_slug}
+            realmName={p.realm_name}
+            region={p.region}
+            activeSpecId={p.active_spec_id}
+            combinedBestTimeMs={p.combined_best_time_ms}
+            avatarUrl={p.avatar_url}
+          />
+        ))}
+      </div>
+    )
+  }
+</section>

diff --git a/web/src/components/Home/TopPlay
ersList/TopPlayersList.astro b/web/src/compo
nents/Home/TopPlayersList/TopPlayersList.ast
ro
new file mode 100644
index 0000000..821f7a2
--- /dev/null
+++ b/web/src/components/Home/TopPlayersList
/TopPlayersList.astro
@@ -0,0 +1,55 @@
+---
+import "./TopPlayersList.scss";
+import PlayerRankRow from "../PlayerRankRow
/PlayerRankRow.astro";
+import type { HomePlayerEntry } from "../..
/../lib/types";
+
+interface Props {
+  title: string;
+  players: HomePlayerEntry[];
+  scope: "global" | "regional";
+  viewAllHref?: string;
+}
+
+const { title, players, scope, viewAllHref 
} = Astro.props;
+
+// Pick the bracket that matches the scope 
of this list.
+// Global list shows global rank; regional 
list shows regional rank - color the badge a
ccordingly.
+function bracketFor(p: HomePlayerEntry): st
ring | undefined {
+  return scope === "global" ? p.global_rank
ing_bracket : p.regional_ranking_bracket;
+}
+---
+
+<section class="top-players-list">
+  <header class="top-players-list__header">
+    <h3>{title}</h3>
+    {
+      viewAllHref && (
+        <a class="top-players-list__view-al
l" href={viewAllHref}>
+          View all ?
+        </a>
+      )
+    }
+  </header>
+
+  {
+    players.length === 0 ? (
+      <p class="top-players-list__empty">No
 ranked players in this scope yet.</p>
+    ) : (
+      <div class="top-players-list__rows">
+        {players.map((p) => (
+          <PlayerRankRow
+            rank={p.rank}
+            bracket={bracketFor(p)}
+            name={p.name}
+            realmSlug={p.realm_slug}
+            realmName={p.realm_name}
+            region={p.region}
+            activeSpecId={p.active_spec_id}
+            combinedBestTimeMs={p.combined_
best_time_ms}
+            avatarUrl={p.avatar_url}
+          />
+        ))}
+      </div>
+    )
+  }
+</section>
 

diff --git a/web/src/components/Home/TopPlayersList/TopPlayersList.scss b/web/src/
components/Home/TopPlayersList/TopPlayersList.scss
new file mode 100644
index 0000000..af3f48e
--- /dev/null
+++ b/web/src/components/Home/TopPlayersList/TopPlayersList.scss
@@ -0,0 +1,44 @@
+@use "../../../styles/core/tokens" as *;
+
+.top-players-list {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-md;
+
+  &__header {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: $spacing-md;
+
+    h3 {
+      margin: 0;
+      font-size: $font-size-xl;
+      color: var(--text-primary);
+      font-weight: $font-weight-semibold;
+    }
+  }
+
+  &__view-all {
+    color: var(--text-tertiary);
+    text-decoration: none;
+    font-size: $font-size-sm;
+    transition: color 0.15s ease;
+
+    &:hover {
+      color: var(--highlight-color);
+    }
+  }
+
+  &__empty {
+    color: var(--text-tertiary);
+    text-align: center;
+    padding: $spacing-lg;
+  }
+
+  &__rows {
+    display: flex;
+    flex-direction: column;
+    gap: $spacing-xs;
+  }
+}

diff --git a/web/src/components/Home/TopPlay
ersList/TopPlayersList.scss b/web/src/compon
ents/Home/TopPlayersList/TopPlayersList.scss
new file mode 100644
index 0000000..af3f48e
--- /dev/null
+++ b/web/src/components/Home/TopPlayersList
/TopPlayersList.scss
@@ -0,0 +1,44 @@
+@use "../../../styles/core/tokens" as *;
+
+.top-players-list {
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-md;
+
+  &__header {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: $spacing-md;
+
+    h3 {
+      margin: 0;
+      font-size: $font-size-xl;
+      color: var(--text-primary);
+      font-weight: $font-weight-semibold;
+    }
+  }
+
+  &__view-all {
+    color: var(--text-tertiary);
+    text-decoration: none;
+    font-size: $font-size-sm;
+    transition: color 0.15s ease;
+
+    &:hover {
+      color: var(--highlight-color);
+    }
+  }
+
+  &__empty {
+    color: var(--text-tertiary);
+    text-align: center;
+    padding: $spacing-lg;
+  }
+
+  &__rows {
+    display: flex;
+    flex-direction: column;
+    gap: $spacing-xs;
+  }
+}
 

diff --git a/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.
astro b/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.astro
index 52e29a3..3a7a4e3 100644
--- a/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.astro
+++ b/web/src/components/Leaderboard/LeaderboardTypeNav/LeaderboardTypeNav.astro
@@ -3,7 +3,7 @@ import "./LeaderboardTypeNav.scss";
 
 interface Props {
   currentType?: "dungeon" | "player";
-  currentTab?: "dungeon" | "player" | "status";
+  currentTab?: "home" | "dungeon" | "player" | "status";
   currentRegion?: string;
   currentRealm?: string;
   currentSeason?: number;
@@ -47,6 +47,12 @@ if (currentRegion === "global") {
 ---
 
 <nav class="leaderboard-type-nav">
+  <a
+    href="/"
+    class:list={["nav-tab", { active: currentTab === "home" }]}
+  >
+    Home
+  </a>
   <a
     href={dungeonUrl}
     class:list={["nav-tab", { active: currentTab === "dungeon" }]}

diff --git a/web/src/components/Leaderboard/
LeaderboardTypeNav/LeaderboardTypeNav.astro 
b/web/src/components/Leaderboard/Leaderboard
TypeNav/LeaderboardTypeNav.astro
index 52e29a3..3a7a4e3 100644
--- a/web/src/components/Leaderboard/Leaderb
oardTypeNav/LeaderboardTypeNav.astro
+++ b/web/src/components/Leaderboard/Leaderb
oardTypeNav/LeaderboardTypeNav.astro
@@ -3,7 +3,7 @@ import "./LeaderboardTypeNav
.scss";
 
 interface Props {
   currentType?: "dungeon" | "player";
-  currentTab?: "dungeon" | "player" | "stat
us";
+  currentTab?: "home" | "dungeon" | "player
" | "status";
   currentRegion?: string;
   currentRealm?: string;
   currentSeason?: number;
@@ -47,6 +47,12 @@ if (currentRegion === "gl
obal") {
 ---
 
 <nav class="leaderboard-type-nav">
+  <a
+    href="/"
+    class:list={["nav-tab", { active: curre
ntTab === "home" }]}
+  >
+    Home
+  </a>
   <a
     href={dungeonUrl}
     class:list={["nav-tab", { active: curre
ntTab === "dungeon" }]}
 

diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 7dabde9..f05aaf9 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -172,6 +172,42 @@ export interface APIResponse<T> {
   error?: string;
 }
 
+// Home page types - mirror nix/pkgs/ookstats/src/internal/generator/home.go
+// (HomeRunEntry / HomePlayerEntry). Keep field names in sync with that file.
+
+export interface HomeRunEntry {
+  rank?: number;
+  bracket?: string;
+  run_id: number;
+  dungeon_id: number;
+  dungeon_name: string;
+  dungeon_slug: string;
+  duration_ms: number;
+  completed_timestamp: number;
+  // Only set on entries in `recent_top_runs` (cross-season feed):
+  season_id?: number;
+  rankings?: { global?: number };
+  team_members: TeamMember[];
+}
+
+export interface HomePlayerEntry {
+  rank: number;
+  player_id: number;
+  name: string;
+  realm_slug: string;
+  realm_name?: string;
+  region: string;
+  class_name?: string;
+  active_spec_id?: number;
+  active_spec_name?: string;
+  combined_best_time_ms: number;
+  global_ranking?: number;
+  global_ranking_bracket?: string;
+  regional_ranking?: number;
+  regional_ranking_bracket?: string;
+  avatar_url?: string;
+}
+
 // frontend component props
 export interface LeaderboardTableProps {
   initialData?: LeaderboardData;

diff --git a/web/src/lib/types.ts b/web/src/
lib/types.ts
index 7dabde9..f05aaf9 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -172,6 +172,42 @@ export interface APIRes
ponse<T> {
   error?: string;
 }
 
+// Home page types - mirror nix/pkgs/ooksta
ts/src/internal/generator/home.go
+// (HomeRunEntry / HomePlayerEntry). Keep f
ield names in sync with that file.
+
+export interface HomeRunEntry {
+  rank?: number;
+  bracket?: string;
+  run_id: number;
+  dungeon_id: number;
+  dungeon_name: string;
+  dungeon_slug: string;
+  duration_ms: number;
+  completed_timestamp: number;
+  // Only set on entries in `recent_top_run
s` (cross-season feed):
+  season_id?: number;
+  rankings?: { global?: number };
+  team_members: TeamMember[];
+}
+
+export interface HomePlayerEntry {
+  rank: number;
+  player_id: number;
+  name: string;
+  realm_slug: string;
+  realm_name?: string;
+  region: string;
+  class_name?: string;
+  active_spec_id?: number;
+  active_spec_name?: string;
+  combined_best_time_ms: number;
+  global_ranking?: number;
+  global_ranking_bracket?: string;
+  regional_ranking?: number;
+  regional_ranking_bracket?: string;
+  avatar_url?: string;
+}
+
 // frontend component props
 export interface LeaderboardTableProps {
   initialData?: LeaderboardData;
 

diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
index 4929860..6899b70 100644
--- a/web/src/lib/utils.ts
+++ b/web/src/lib/utils.ts
@@ -26,6 +26,14 @@ export function formatTimestamp(timestamp: number): string {
   });
 }
 
+// Compact "Mar 27" - for dense layouts where formatTimestamp's time-of-day eats 
space.
+export function formatShortDate(timestamp: number): string {
+  return new Date(timestamp).toLocaleDateString("en-US", {
+    month: "short",
+    day: "numeric",
+  });
+}
+
 export function formatSimulationDate(timestamp: string | number): string {
   return new Date(timestamp).toLocaleString("en-US", {
     year: "numeric",

diff --git a/web/src/lib/utils.ts b/web/src/
lib/utils.ts
index 4929860..6899b70 100644
--- a/web/src/lib/utils.ts
+++ b/web/src/lib/utils.ts
@@ -26,6 +26,14 @@ export function formatTim
estamp(timestamp: number): string {
   });
 }
 
+// Compact "Mar 27" - for dense layouts whe
re formatTimestamp's time-of-day eats space.
+export function formatShortDate(timestamp: 
number): string {
+  return new Date(timestamp).toLocaleDateSt
ring("en-US", {
+    month: "short",
+    day: "numeric",
+  });
+}
+
 export function formatSimulationDate(timest
amp: string | number): string {
   return new Date(timestamp).toLocaleString
("en-US", {
     year: "numeric",
 

diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro
index 5041278..7c4d694 100644
--- a/web/src/pages/index.astro
+++ b/web/src/pages/index.astro
@@ -1,15 +1,266 @@
 ---
-import SimulationLayout from "../layouts/SimulationLayout.astro";
+import PageLayout from "../layouts/PageLayout.astro";
+import PlayerSearch from "../components/PlayerSearch/PlayerSearch.astro";
+import LeaderboardTypeNav from "../components/Leaderboard/LeaderboardTypeNav/Lead
erboardTypeNav.astro";
+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";
+
+export const prerender = false;
+
+let home: any = null;
+try {
+  const response = await fetch(`${Astro.url.origin}/api/home.json`);
+  if (response.ok) {
+    home = await response.json();
+  }
+} catch (error) {
+  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
))
+  : [];
+
+const generatedAt = home?.generated_at ? new Date(home.generated_at) : null;
 ---
 
-<SimulationLayout title="Simulations" description="Redirecting...">
-  <div class="card">
-    <p>Redirecting to benchmarks...</p>
+<PageLayout
+  title="WoW Mists of Pandaria Challenge Mode Leaderboards"
+  description="Top challenge mode times, recent record-setting runs, and the worl
d's best players for World of Warcraft: Mists of Pandaria Classic."
+>
+  <div class="home">
+    {
+      !home && (
+        <div class="home__error">
+          <h2>Couldn't load home data</h2>
+          <p>The home.json file is missing or unreachable. Try regenerating it vi
a <code>ookstats generate home</code>.</p>
+        </div>
+      )
+    }
+
+    {
+      home && (
+        <>
+          <header class="home__hero">
+            <h1>Challenge Mode Leaderboards</h1>
+            {generatedAt && (
+              <span class="home__updated">
+                Data updated {generatedAt.toLocaleString("en-US", { month: "short
", day: "numeric", hour: "2-digit", minute: "2-digit" })}
+              </span>
+            )}
+          </header>
+
+          <PlayerSearch />
+
+          <LeaderboardTypeNav
+            currentTab="home"
+            currentSeason={parseInt(currentSeasonKey)}
+          />
+
+          {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>
+          )}
+
+          {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)}
+                  />
+                ))}
+              </div>
+            </section>
+          )}
+
+          <section class="home__section">
+            <RecentRuns runs={recentRuns} />
+          </section>
+        </>
+      )
+    }
   </div>
-  <script is:inline>
-    (function () {
-      var url = new URL(window.location.href);
-      window.location.replace("/simulation/benchmark" + url.search);
-    })();
-  </script>
-</SimulationLayout>
+</PageLayout>
+
+<style lang="scss">
+  @use "../styles/core/tokens" as *;
+
+  // Match CM page wrapper (.leaderboard-page on /challenge-mode/* pages)
+  // so navigating between sections feels cohesive. No flex-gap on the wrapper -
+  // each child component (LeaderboardHeader-style hero, PlayerSearch, nav) carri
es
+  // its own bottom margin to match the CM page rhythm.
+  .home {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 20px;
+  }
+
+  .home__error {
+    background: var(--bg-secondary);
+    border: 1px solid var(--border-color);
+    border-radius: $radius-lg;
+    padding: $spacing-2xl;
+    text-align: center;
+    color: var(--text-secondary);
+    code {
+      background: var(--bg-primary);
+      padding: 2px 6px;
+      border-radius: $radius-sm;
+      color: var(--highlight-color);
+    }
+  }
+
+  // Mirror LeaderboardHeader's spacing/typography exactly so home title flows
+  // visually like every other CM-section page header.
+  .home__hero {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: $spacing-lg;
+    flex-wrap: wrap;
+    margin: 8px 0 16px 0;
+
+    h1 {
+      margin: 0;
+      font-size: 2em;
+      color: var(--text-primary);
+      font-weight: $font-weight-bold;
+    }
+  }
+
+  .home__updated {
+    color: var(--text-tertiary);
+    font-size: $font-size-sm;
+  }
+
+  .home__section {
+    display: flex;
+    flex-direction: column;
+    gap: $spacing-lg;
+
+    & + & {
+      margin-top: $spacing-2xl;
+    }
+  }
+
+  .home__section-header {
+    h2 {
+      margin: 0;
+      font-size: $font-size-2xl;
+      color: var(--text-primary);
+      font-weight: $font-weight-semibold;
+    }
+  }
+
+  // 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;
+  }
+
+  // Mobile overrides - placed at the end of the stylesheet so they win the
+  // cascade over the desktop rules above (same selector specificity).
+  @media (max-width: 768px) {
+    .home {
+      padding: 15px;
+    }
+
+    // Mirror LeaderboardHeader's mobile font-size drop (~1.5em).
+    .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 5041278..7c4d694 100644
--- a/web/src/pages/index.astro
+++ b/web/src/pages/index.astro
@@ -1,15 +1,266 @@
 ---
-import SimulationLayout from "../layouts/Si
mulationLayout.astro";
+import PageLayout from "../layouts/PageLayo
ut.astro";
+import PlayerSearch from "../components/Pla
yerSearch/PlayerSearch.astro";
+import LeaderboardTypeNav from "../componen
ts/Leaderboard/LeaderboardTypeNav/Leaderboar
dTypeNav.astro";
+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";
+
+export const prerender = false;
+
+let home: any = null;
+try {
+  const response = await fetch(`${Astro.url
.origin}/api/home.json`);
+  if (response.ok) {
+    home = await response.json();
+  }
+} catch (error) {
+  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))
+  : [];
+
+const generatedAt = home?.generated_at ? ne
w Date(home.generated_at) : null;
 ---
 
-<SimulationLayout title="Simulations" descr
iption="Redirecting...">
-  <div class="card">
-    <p>Redirecting to benchmarks...</p>
+<PageLayout
+  title="WoW Mists of Pandaria Challenge Mo
de Leaderboards"
+  description="Top challenge mode times, re
cent record-setting runs, and the world's be
st players for World of Warcraft: Mists of P
andaria Classic."
+>
+  <div class="home">
+    {
+      !home && (
+        <div class="home__error">
+          <h2>Couldn't load home data</h2>
+          <p>The home.json file is missing 
or unreachable. Try regenerating it via <cod
e>ookstats generate home</code>.</p>
+        </div>
+      )
+    }
+
+    {
+      home && (
+        <>
+          <header class="home__hero">
+            <h1>Challenge Mode Leaderboards
</h1>
+            {generatedAt && (
+              <span class="home__updated">
+                Data updated {generatedAt.t
oLocaleString("en-US", { month: "short", day
: "numeric", hour: "2-digit", minute: "2-dig
it" })}
+              </span>
+            )}
+          </header>
+
+          <PlayerSearch />
+
+          <LeaderboardTypeNav
+            currentTab="home"
+            currentSeason={parseInt(current
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>
+          )}
+
+          {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)}
+                  />
+                ))}
+              </div>
+            </section>
+          )}
+
+          <section class="home__section">
+            <RecentRuns runs={recentRuns} /
>
+          </section>
+        </>
+      )
+    }
   </div>
-  <script is:inline>
-    (function () {
-      var url = new URL(window.location.hre
f);
-      window.location.replace("/simulation/
benchmark" + url.search);
-    })();
-  </script>
-</SimulationLayout>
+</PageLayout>
+
+<style lang="scss">
+  @use "../styles/core/tokens" as *;
+
+  // Match CM page wrapper (.leaderboard-pa
ge on /challenge-mode/* pages)
+  // so navigating between sections feels c
ohesive. No flex-gap on the wrapper -
+  // each child component (LeaderboardHeade
r-style hero, PlayerSearch, nav) carries
+  // its own bottom margin to match the CM 
page rhythm.
+  .home {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 20px;
+  }
+
+  .home__error {
+    background: var(--bg-secondary);
+    border: 1px solid var(--border-color);
+    border-radius: $radius-lg;
+    padding: $spacing-2xl;
+    text-align: center;
+    color: var(--text-secondary);
+    code {
+      background: var(--bg-primary);
+      padding: 2px 6px;
+      border-radius: $radius-sm;
+      color: var(--highlight-color);
+    }
+  }
+
+  // Mirror LeaderboardHeader's spacing/typ
ography exactly so home title flows
+  // visually like every other CM-section p
age header.
+  .home__hero {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: $spacing-lg;
+    flex-wrap: wrap;
+    margin: 8px 0 16px 0;
+
+    h1 {
+      margin: 0;
+      font-size: 2em;
+      color: var(--text-primary);
+      font-weight: $font-weight-bold;
+    }
+  }
+
+  .home__updated {
+    color: var(--text-tertiary);
+    font-size: $font-size-sm;
+  }
+
+  .home__section {
+    display: flex;
+    flex-direction: column;
+    gap: $spacing-lg;
+
+    & + & {
+      margin-top: $spacing-2xl;
+    }
+  }
+
+  .home__section-header {
+    h2 {
+      margin: 0;
+      font-size: $font-size-2xl;
+      color: var(--text-primary);
+      font-weight: $font-weight-semibold;
+    }
+  }
+
+  // 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;
+  }
+
+  // Mobile overrides - placed at the end o
f the stylesheet so they win the
+  // cascade over the desktop rules above (
same selector specificity).
+  @media (max-width: 768px) {
+    .home {
+      padding: 15px;
+    }
+
+    // Mirror LeaderboardHeader's mobile fo
nt-size drop (~1.5em).
+    .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