diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt
new file mode 100644
index 000000000..1d3d8e86c
--- /dev/null
+++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt
@@ -0,0 +1,6 @@
+Branch Validation: PASS
+
+PASS - Current branch: pr/26174-ALFA-009-game-hub-parent-child-table-layout.
+PASS - Stack base: pr/26174-ALFA-008-alpha-stack-final-validation.
+PASS - Changes are scoped to Game Hub Open Games parent/child table layout, targeted tests, and required reports.
+PASS - No merge to main performed.
diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt
new file mode 100644
index 000000000..60594770a
--- /dev/null
+++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt
@@ -0,0 +1,6 @@
+Manual Validation Notes: PASS
+
+PASS - Confirmed Open Games renders as a parent table with expected headers.
+PASS - Confirmed Demo Game expands to a child Game Summary table and collapses again.
+PASS - Confirmed Open Game buttons still open games through the existing repository path.
+PASS - Confirmed empty and unavailable Open Games states still render without game rows.
diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt
new file mode 100644
index 000000000..a26e9d858
--- /dev/null
+++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt
@@ -0,0 +1,10 @@
+Requirement Checklist: PASS
+
+PASS - Parent table is Open Games.
+PASS - Each parent row represents one game.
+PASS - Game rows can expand and collapse.
+PASS - Used the existing parent-row plus expanded child-row table pattern.
+PASS - Preserved existing Local API/service contract.
+PASS - No browser-owned project data was added.
+PASS - No page-local project arrays were added.
+PASS - Empty and unavailable states remain explicit; no silent fallbacks were added.
diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt
new file mode 100644
index 000000000..d38885c3a
--- /dev/null
+++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt
@@ -0,0 +1,4 @@
+Validation Lane: PASS
+
+PASS - npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub creates, opens, and deletes mock games"
+PASS - npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub shows a creator-safe empty state|Game Hub shows a creator-safe unavailable state"
diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md
new file mode 100644
index 000000000..851c1a19e
--- /dev/null
+++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md
@@ -0,0 +1,17 @@
+# PR_26174_ALFA_009-game-hub-parent-child-table-layout
+
+## Purpose
+
+Convert Game Hub Open Games into a reusable parent-table / child-table structure.
+
+## Summary
+
+- Rendered Open Games as a parent data table with one row per game.
+- Added expand/collapse controls for each game row.
+- Added a nested Game Summary child table in the expanded row using the existing parent-row plus expanded-child-row pattern.
+- Preserved existing Local API/service repository calls and Open button behavior.
+
+## Validation
+
+PASS - `npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub creates, opens, and deletes mock games"`
+PASS - `npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub shows a creator-safe empty state|Game Hub shows a creator-safe unavailable state"`
diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt
index 95d9ae9f4..c4c8e6cf9 100644
--- a/docs_build/dev/reports/codex_changed_files.txt
+++ b/docs_build/dev/reports/codex_changed_files.txt
@@ -1,8 +1,9 @@
+toolbox/game-hub/game-hub.js
+tests/playwright/tools/GameHubMockRepository.spec.mjs
docs_build/dev/reports/codex_review.diff
docs_build/dev/reports/codex_changed_files.txt
-docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation.md
-docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-final-stack-report.md
-docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-branch-validation.txt
-docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-requirement-checklist.txt
-docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-validation-lane.txt
-docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-manual-validation-notes.txt
+docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md
+docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt
+docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt
+docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt
+docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt
diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff
index 7a8ae6ec0..8b90ad570 100644
--- a/docs_build/dev/reports/codex_review.diff
+++ b/docs_build/dev/reports/codex_review.diff
@@ -1 +1,195 @@
-No executable code changes in PR_26174_ALFA_008; final validation and reporting only.
+diff --git a/tests/playwright/tools/GameHubMockRepository.spec.mjs b/tests/playwright/tools/GameHubMockRepository.spec.mjs
+index e11631269..695b0a609 100644
+--- a/tests/playwright/tools/GameHubMockRepository.spec.mjs
++++ b/tests/playwright/tools/GameHubMockRepository.spec.mjs
+@@ -264,10 +264,32 @@ test("Game Hub creates, opens, and deletes mock games", async ({ page }) => {
+ await expect(page.locator("[data-game-list]")).toContainText("Gravity Demo");
+ await expect(page.locator("[data-game-list]")).toContainText("Collision Demo");
+ await expect(page.locator("[data-game-list]")).toContainText("Camera Follow Demo");
++ await expect(page.locator("[data-game-parent-table='open-games']")).toHaveAttribute("aria-label", "Open Games");
++ await expect(page.locator("[data-game-parent-table='open-games'] caption")).toHaveText("Open Games");
++ await expect(page.locator("[data-game-parent-table='open-games'] thead th")).toHaveText([
++ "Game",
++ "Purpose",
++ "Status",
++ "Owner",
++ "Actions",
++ ]);
+ const demoGameRow = page.locator("[data-game-row='demo-game']");
+ await expect(demoGameRow.locator("> .status")).toHaveCount(0);
++ await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "false");
+ await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveClass(/primary/);
+ await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveAttribute("aria-current", "true");
++ await demoGameRow.locator("[data-game-toggle='demo-game']").click();
++ await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "true");
++ await expect(page.locator("[data-game-row='demo-game'] + [data-game-expanded-row='demo-game']")).toHaveCount(1);
++ await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary']")).toContainText("Game Summary");
++ await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary'] tbody tr")).toHaveText([
++ "ProjectDemo Game",
++ "PurposeGame",
++ "StatusUnder Construction",
++ "OwnerUser 1",
++ ]);
++ await demoGameRow.locator("[data-game-toggle='demo-game']").click();
++ await expect(page.locator("[data-game-expanded-row='demo-game']")).toHaveCount(0);
+
+ await page.getByLabel("Game Name").fill("Launch Test Game");
+ await page.getByRole("button", { name: "Create Game" }).click();
+diff --git a/toolbox/game-hub/game-hub.js b/toolbox/game-hub/game-hub.js
+index 2d6f321e4..121e82562 100644
+--- a/toolbox/game-hub/game-hub.js
++++ b/toolbox/game-hub/game-hub.js
+@@ -39,6 +39,10 @@ const elements = {
+ tableCounts: document.querySelector("[data-game-table-counts]"),
+ };
+
++const state = {
++ expandedGameId: "",
++};
++
+ function setText(element, value) {
+ if (element && typeof element.forEach === "function" && !element.nodeType) {
+ element.forEach((item) => {
+@@ -225,6 +229,90 @@ function createGameListStatus(message, state) {
+ return emptyState;
+ }
+
++function createCell(value, tagName = "td") {
++ const cell = document.createElement(tagName);
++ cell.textContent = value;
++ return cell;
++}
++
++function createGameToggleButton(game, expanded) {
++ const button = document.createElement("button");
++ button.className = expanded ? "btn btn--compact primary" : "btn btn--compact";
++ button.type = "button";
++ button.dataset.gameToggle = game.id;
++ button.setAttribute("aria-expanded", String(expanded));
++ button.setAttribute("aria-controls", `game-child-${game.id}`);
++ button.textContent = game.name;
++ return button;
++}
++
++function renderGameSummaryChildTable(parent, game) {
++ const wrapper = document.createElement("div");
++ wrapper.className = "table-wrapper";
++ const table = document.createElement("table");
++ table.className = "data-table data-table--fixed";
++ table.dataset.gameChildTable = "summary";
++ table.setAttribute("aria-label", `${game.name} game summary`);
++ table.innerHTML = "
Game Summary| Field | Value |
";
++ const body = document.createElement("tbody");
++ [
++ ["Project", game.name],
++ ["Purpose", game.purpose],
++ ["Status", game.status],
++ ["Owner", game.ownerDisplayName],
++ ].forEach(([label, value]) => {
++ const row = document.createElement("tr");
++ row.append(createCell(label, "th"), createCell(value || "Not set"));
++ row.firstElementChild.scope = "row";
++ body.append(row);
++ });
++ table.append(body);
++ wrapper.append(table);
++ parent.append(wrapper);
++}
++
++function renderExpandedGameRow(tbody, game) {
++ const row = document.createElement("tr");
++ row.dataset.gameExpandedRow = game.id;
++ row.id = `game-child-${game.id}`;
++ const content = document.createElement("td");
++ content.colSpan = 5;
++ const stack = document.createElement("div");
++ stack.className = "content-stack content-stack--compact";
++ renderGameSummaryChildTable(stack, game);
++ content.append(stack);
++ row.append(content);
++ tbody.append(row);
++}
++
++function renderGameParentRow(tbody, game, activeGame) {
++ const expanded = state.expandedGameId === game.id;
++ const row = document.createElement("tr");
++ row.dataset.gameRow = game.id;
++ if (activeGame?.id === game.id) {
++ row.dataset.gameActive = "true";
++ }
++
++ const nameCell = document.createElement("th");
++ nameCell.scope = "row";
++ nameCell.append(createGameToggleButton(game, expanded));
++ row.append(
++ nameCell,
++ createCell(game.purpose || "Game"),
++ createCell(game.status || "No status"),
++ createCell(game.ownerDisplayName || "No owner"),
++ );
++
++ const actions = document.createElement("td");
++ actions.append(createGameButton(game, activeGame?.id === game.id));
++ row.append(actions);
++ tbody.append(row);
++
++ if (expanded) {
++ renderExpandedGameRow(tbody, game);
++ }
++}
++
+ function renderProjectInformation(activeGame, currentMember, progress) {
+ if (!elements.projectRecordsTable) {
+ return;
+@@ -276,25 +364,18 @@ function renderGameList() {
+ return;
+ }
+
+- listResult.forEach((game) => {
+- const row = document.createElement("article");
+- row.className = "callout";
+- row.dataset.gameRow = game.id;
+-
+- const title = document.createElement("h4");
+- title.textContent = game.name;
+-
+- const meta = document.createElement("p");
+- meta.className = "eyebrow";
+- meta.textContent = `${game.purpose} | ${game.status} | ${game.ownerDisplayName}`;
+-
+- const isActive = activeGame?.id === game.id;
+- const action = createGameButton(game, isActive);
+-
+- row.append(title, meta, action);
+-
+- elements.gameList.append(row);
+- });
++ const wrapper = document.createElement("div");
++ wrapper.className = "table-wrapper";
++ const table = document.createElement("table");
++ table.className = "data-table data-table--fixed";
++ table.dataset.gameParentTable = "open-games";
++ table.setAttribute("aria-label", "Open Games");
++ table.innerHTML = "Open Games| Game | Purpose | Status | Owner | Actions |
";
++ const body = document.createElement("tbody");
++ listResult.forEach((game) => renderGameParentRow(body, game, activeGame));
++ table.append(body);
++ wrapper.append(table);
++ elements.gameList.append(wrapper);
+ }
+
+ function renderMembersTable(activeGame) {
+@@ -471,6 +552,13 @@ elements.form?.addEventListener("submit", (event) => {
+ });
+
+ elements.gameList?.addEventListener("click", (event) => {
++ const toggle = event.target.closest("[data-game-toggle]");
++ if (toggle) {
++ state.expandedGameId = state.expandedGameId === toggle.dataset.gameToggle ? "" : toggle.dataset.gameToggle;
++ renderWorkspace();
++ return;
++ }
++
+ const button = event.target.closest("[data-game-open]");
+
+ if (!button) {
diff --git a/tests/playwright/tools/GameHubMockRepository.spec.mjs b/tests/playwright/tools/GameHubMockRepository.spec.mjs
index e11631269..695b0a609 100644
--- a/tests/playwright/tools/GameHubMockRepository.spec.mjs
+++ b/tests/playwright/tools/GameHubMockRepository.spec.mjs
@@ -264,10 +264,32 @@ test("Game Hub creates, opens, and deletes mock games", async ({ page }) => {
await expect(page.locator("[data-game-list]")).toContainText("Gravity Demo");
await expect(page.locator("[data-game-list]")).toContainText("Collision Demo");
await expect(page.locator("[data-game-list]")).toContainText("Camera Follow Demo");
+ await expect(page.locator("[data-game-parent-table='open-games']")).toHaveAttribute("aria-label", "Open Games");
+ await expect(page.locator("[data-game-parent-table='open-games'] caption")).toHaveText("Open Games");
+ await expect(page.locator("[data-game-parent-table='open-games'] thead th")).toHaveText([
+ "Game",
+ "Purpose",
+ "Status",
+ "Owner",
+ "Actions",
+ ]);
const demoGameRow = page.locator("[data-game-row='demo-game']");
await expect(demoGameRow.locator("> .status")).toHaveCount(0);
+ await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "false");
await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveClass(/primary/);
await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveAttribute("aria-current", "true");
+ await demoGameRow.locator("[data-game-toggle='demo-game']").click();
+ await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "true");
+ await expect(page.locator("[data-game-row='demo-game'] + [data-game-expanded-row='demo-game']")).toHaveCount(1);
+ await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary']")).toContainText("Game Summary");
+ await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary'] tbody tr")).toHaveText([
+ "ProjectDemo Game",
+ "PurposeGame",
+ "StatusUnder Construction",
+ "OwnerUser 1",
+ ]);
+ await demoGameRow.locator("[data-game-toggle='demo-game']").click();
+ await expect(page.locator("[data-game-expanded-row='demo-game']")).toHaveCount(0);
await page.getByLabel("Game Name").fill("Launch Test Game");
await page.getByRole("button", { name: "Create Game" }).click();
diff --git a/toolbox/game-hub/game-hub.js b/toolbox/game-hub/game-hub.js
index 2d6f321e4..121e82562 100644
--- a/toolbox/game-hub/game-hub.js
+++ b/toolbox/game-hub/game-hub.js
@@ -39,6 +39,10 @@ const elements = {
tableCounts: document.querySelector("[data-game-table-counts]"),
};
+const state = {
+ expandedGameId: "",
+};
+
function setText(element, value) {
if (element && typeof element.forEach === "function" && !element.nodeType) {
element.forEach((item) => {
@@ -225,6 +229,90 @@ function createGameListStatus(message, state) {
return emptyState;
}
+function createCell(value, tagName = "td") {
+ const cell = document.createElement(tagName);
+ cell.textContent = value;
+ return cell;
+}
+
+function createGameToggleButton(game, expanded) {
+ const button = document.createElement("button");
+ button.className = expanded ? "btn btn--compact primary" : "btn btn--compact";
+ button.type = "button";
+ button.dataset.gameToggle = game.id;
+ button.setAttribute("aria-expanded", String(expanded));
+ button.setAttribute("aria-controls", `game-child-${game.id}`);
+ button.textContent = game.name;
+ return button;
+}
+
+function renderGameSummaryChildTable(parent, game) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "table-wrapper";
+ const table = document.createElement("table");
+ table.className = "data-table data-table--fixed";
+ table.dataset.gameChildTable = "summary";
+ table.setAttribute("aria-label", `${game.name} game summary`);
+ table.innerHTML = "Game Summary| Field | Value |
";
+ const body = document.createElement("tbody");
+ [
+ ["Project", game.name],
+ ["Purpose", game.purpose],
+ ["Status", game.status],
+ ["Owner", game.ownerDisplayName],
+ ].forEach(([label, value]) => {
+ const row = document.createElement("tr");
+ row.append(createCell(label, "th"), createCell(value || "Not set"));
+ row.firstElementChild.scope = "row";
+ body.append(row);
+ });
+ table.append(body);
+ wrapper.append(table);
+ parent.append(wrapper);
+}
+
+function renderExpandedGameRow(tbody, game) {
+ const row = document.createElement("tr");
+ row.dataset.gameExpandedRow = game.id;
+ row.id = `game-child-${game.id}`;
+ const content = document.createElement("td");
+ content.colSpan = 5;
+ const stack = document.createElement("div");
+ stack.className = "content-stack content-stack--compact";
+ renderGameSummaryChildTable(stack, game);
+ content.append(stack);
+ row.append(content);
+ tbody.append(row);
+}
+
+function renderGameParentRow(tbody, game, activeGame) {
+ const expanded = state.expandedGameId === game.id;
+ const row = document.createElement("tr");
+ row.dataset.gameRow = game.id;
+ if (activeGame?.id === game.id) {
+ row.dataset.gameActive = "true";
+ }
+
+ const nameCell = document.createElement("th");
+ nameCell.scope = "row";
+ nameCell.append(createGameToggleButton(game, expanded));
+ row.append(
+ nameCell,
+ createCell(game.purpose || "Game"),
+ createCell(game.status || "No status"),
+ createCell(game.ownerDisplayName || "No owner"),
+ );
+
+ const actions = document.createElement("td");
+ actions.append(createGameButton(game, activeGame?.id === game.id));
+ row.append(actions);
+ tbody.append(row);
+
+ if (expanded) {
+ renderExpandedGameRow(tbody, game);
+ }
+}
+
function renderProjectInformation(activeGame, currentMember, progress) {
if (!elements.projectRecordsTable) {
return;
@@ -276,25 +364,18 @@ function renderGameList() {
return;
}
- listResult.forEach((game) => {
- const row = document.createElement("article");
- row.className = "callout";
- row.dataset.gameRow = game.id;
-
- const title = document.createElement("h4");
- title.textContent = game.name;
-
- const meta = document.createElement("p");
- meta.className = "eyebrow";
- meta.textContent = `${game.purpose} | ${game.status} | ${game.ownerDisplayName}`;
-
- const isActive = activeGame?.id === game.id;
- const action = createGameButton(game, isActive);
-
- row.append(title, meta, action);
-
- elements.gameList.append(row);
- });
+ const wrapper = document.createElement("div");
+ wrapper.className = "table-wrapper";
+ const table = document.createElement("table");
+ table.className = "data-table data-table--fixed";
+ table.dataset.gameParentTable = "open-games";
+ table.setAttribute("aria-label", "Open Games");
+ table.innerHTML = "Open Games| Game | Purpose | Status | Owner | Actions |
";
+ const body = document.createElement("tbody");
+ listResult.forEach((game) => renderGameParentRow(body, game, activeGame));
+ table.append(body);
+ wrapper.append(table);
+ elements.gameList.append(wrapper);
}
function renderMembersTable(activeGame) {
@@ -471,6 +552,13 @@ elements.form?.addEventListener("submit", (event) => {
});
elements.gameList?.addEventListener("click", (event) => {
+ const toggle = event.target.closest("[data-game-toggle]");
+ if (toggle) {
+ state.expandedGameId = state.expandedGameId === toggle.dataset.gameToggle ? "" : toggle.dataset.gameToggle;
+ renderWorkspace();
+ return;
+ }
+
const button = event.target.closest("[data-game-open]");
if (!button) {