From f7acdbb0d5bd2aa32264ccd9d46fde7e67263d82 Mon Sep 17 00:00:00 2001 From: DavidQ Date: Sat, 20 Jun 2026 18:12:46 -0400 Subject: [PATCH] PR_26171_ALPHA_046 rebuild game hub table --- assets/theme-v2/css/tables.css | 18 + .../dev/reports/codex_changed_files.txt | 16 +- docs_build/dev/reports/codex_review.diff | 2118 ++++++++++++++--- .../APPLY_PR.md | 19 + .../BUILD_PR.md | 53 + .../PLAN_PR.md | 27 + .../GameWorkspaceMockRepository.spec.mjs | 378 ++- toolbox/game-workspace/game-workspace.js | 745 +++--- toolbox/game-workspace/index.html | 149 +- 9 files changed, 2607 insertions(+), 916 deletions(-) create mode 100644 docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/APPLY_PR.md create mode 100644 docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/BUILD_PR.md create mode 100644 docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/PLAN_PR.md diff --git a/assets/theme-v2/css/tables.css b/assets/theme-v2/css/tables.css index c39ef19f0..aa98eea80 100644 --- a/assets/theme-v2/css/tables.css +++ b/assets/theme-v2/css/tables.css @@ -111,10 +111,17 @@ td { text-transform: none } +.data-table [data-table-parent-cell], .data-table [data-idea-board-idea-cell] { cursor: pointer } +.data-table [data-table-parent-cell]:focus-visible { + outline: var(--border-width-sm) solid var(--gold); + outline-offset: calc(var(--space-2) * -1) +} + +.table-parent-label, .idea-board-idea-label { display: inline-flex; align-items: center; @@ -126,10 +133,12 @@ td { white-space: nowrap } +.table-parent-label__text, .idea-board-idea-label__text { line-height: var(--line-height-single) } +.table-parent-chevron, .idea-board-idea-chevron { display: inline-block; width: 1em; @@ -144,26 +153,35 @@ td { mask-size: contain } +.table-parent-chevron--down, .idea-board-idea-chevron--down { -webkit-mask-image: url("../images/gfs-chevron-down.svg"); mask-image: url("../images/gfs-chevron-down.svg") } +.table-parent-chevron--up, .idea-board-idea-chevron--up { -webkit-mask-image: url("../images/gfs-chevron-up.svg"); mask-image: url("../images/gfs-chevron-up.svg") } +.table-child-surface, .idea-board-notes-child-surface { margin-left: calc(var(--space-14) * 2); max-width: calc(100% - (var(--space-14) * 2)) } +.table-child-actions, .idea-board-notes-child-actions { padding-left: var(--space-14); padding-top: var(--space-10) } +.table-child-row > td:first-child, +.table-child-indent { + padding-left: calc(var(--space-14) * 2) +} + .tool-form-table { table-layout: fixed; width: 100% diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index e067b2d67..0b8b237c2 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,7 +1,9 @@ -docs_build/dev/PROJECT_INSTRUCTIONS.md -docs_build/dev/PROJECT_MULTI_PC.txt -docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-manual-validation-notes.md -docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-validation.md -docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming.md -docs_build/dev/reports/codex_changed_files.txt -docs_build/dev/reports/codex_review.diff +assets/theme-v2/css/tables.css / updated +docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/APPLY_PR.md / added +docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/BUILD_PR.md / added +docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/PLAN_PR.md / added +docs_build/dev/reports/codex_review.diff / updated +docs_build/dev/reports/codex_changed_files.txt / updated +tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs / updated +toolbox/game-workspace/game-workspace.js / updated +toolbox/game-workspace/index.html / updated diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 7875a6689..b8b7e90e9 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,342 +1,1784 @@ -diff --git a/docs_build/dev/PROJECT_INSTRUCTIONS.md b/docs_build/dev/PROJECT_INSTRUCTIONS.md -index 3c3afa7bd..dbc044b99 100644 ---- a/docs_build/dev/PROJECT_INSTRUCTIONS.md -+++ b/docs_build/dev/PROJECT_INSTRUCTIONS.md -@@ -11,22 +11,38 @@ PLAN_PR → BUILD_PR → APPLY_PR - - PR names MUST follow: - --`PR__<###>-` -+`PR___<###>-` - - Where: - - `YY` = year (2 digit) --- `JJJ` = Julian day (001–365) -+- `JJJ` = Julian day (001-365) -+- `TEAM` = required team ownership token from `docs_build/dev/PROJECT_MULTI_PC.txt` - - `###` = sequence for the day (001+) - - Example: --- `PR_26124_001-palette-baseline` --- `PR_26124_002-tool-fix-asset-manager` -+- `PR_26171_ALPHA_065-message-studio-parent-child-table-foundation` -+- `PR_26171_BETA_069-message-tts-profile-contract-alignment` -+- `PR_26171_GAMMA_071-main-merge-conflict-recovery` -+ -+Branch names MUST mirror PR ownership: -+ -+`pr/--<###>-` -+ -+Branch examples: -+- `pr/26171-ALPHA-065-message-studio-parent-child-table-foundation` -+- `pr/26171-BETA-069-message-tts-profile-contract-alignment` -+- `pr/26171-GAMMA-071-main-merge-conflict-recovery` - - Rules: - - Must be unique per day - - Must be sortable -+- `TEAM` is required -+- `TEAM` ownership comes from `docs_build/dev/PROJECT_MULTI_PC.txt` -+- Team ownership is independent of machine, workspace, laptop, desktop, or environment - - Description must be short and hyphenated - - Do NOT reuse old `PR_11_*` format for new PRs -+- Existing PC/LAPTOP, desktop/laptop, workspace, environment, or machine-parity examples are historical only -+- Future PR reports, recovery reports, validation reports, and manual validation notes must include TEAM ownership - - ## CHATGPT EXECUTION ROLE - -@@ -817,7 +833,7 @@ If the user says `NEXT`: - - Use the current naming standard: - --`PR__<###>-` -+`PR___<###>-` - - Do NOT continue old `PR_11_*` naming for new work. - -@@ -2027,18 +2043,18 @@ Required instruction reads: - - Read `docs_build/dev/PROJECT_INSTRUCTIONS.md`. - - Read `docs_build/dev/PROJECT_MULTI_PC.txt`. - - Treat the newest applicable section in `PROJECT_INSTRUCTIONS.md` as authoritative when rules overlap. --- Treat the current owner/parity section in `PROJECT_MULTI_PC.txt` as authoritative for Team Alpha / Team Beta routing. -+- Treat the current team ownership section in `PROJECT_MULTI_PC.txt` as authoritative for TEAM routing. - - Required pre-change report: - - Codex must report instruction compliance as `PASS` or `FAIL` before making file changes. --- The report must include branch, clean status, PR owner, PR parity, implementation path, validation scope, required report list, and ZIP requirement. -+- The report must include branch, clean status, PR TEAM owner, implementation path, validation scope, required report list, and ZIP requirement. - - Any `FAIL` is a hard stop unless the PR explicitly scopes branch audit or recovery documentation without implementation. - - Hard stops before changes: - - If the current branch is not `main`, HARD STOP. - - If the repository is not clean before the PR branch is created, HARD STOP. --- If the PR owner does not match the Team Alpha / Team Beta ownership map in `PROJECT_MULTI_PC.txt`, HARD STOP. --- If the PR number parity does not match the assigned machine in `PROJECT_MULTI_PC.txt`, HARD STOP. -+- If the PR name does not include a required TEAM token, HARD STOP. -+- If the PR TEAM owner does not match the team ownership map in `PROJECT_MULTI_PC.txt`, HARD STOP. - - If the PR asks for implementation and the implementation path is wrong, HARD STOP. - - If a PR asks for functional parity and only placeholder-only work is possible, HARD STOP and report the missing source or blocker. - - If scoped validation is skipped without a documented reason, HARD STOP. -diff --git a/docs_build/dev/PROJECT_MULTI_PC.txt b/docs_build/dev/PROJECT_MULTI_PC.txt -index 7d2a078ef..1f0ff3eb1 100644 ---- a/docs_build/dev/PROJECT_MULTI_PC.txt -+++ b/docs_build/dev/PROJECT_MULTI_PC.txt -@@ -22,7 +22,7 @@ Requirements - Acceptance Criteria - Dependencies - Priority --Owner (Team Alpha / Team Beta) -+Owner (Team Alpha / Team Beta / Team Gamma) - Recommended Workstream Split - - Instead of arbitrary splits, split by Creator journey. -@@ -451,23 +451,35 @@ That is probably the fastest path to doubling throughput without creating chaos. - - ---------------------------------------------------------------------------------------- - --Current Authoritative Multi-PC Gate -+Current Authoritative Team Ownership Gate - - Codex must read this file before every PR execution. - --Machine parity: -+PR naming: - --Team Alpha / Environment 1: --- Uses even-numbered PR sequence values. --- Example: `PR_26171_064-*`. -+- PR names must include the owning TEAM token: -+ - `PR___<###>-` -+- Branch names must mirror PR ownership: -+ - `pr/--<###>-` -+- `TEAM` is required. -+- Team ownership is independent of machine, workspace, laptop, desktop, or environment. -+- Do not infer PR ownership from PR number parity or current machine. - --Team Beta / Environment 2: --- Uses odd-numbered PR sequence values. --- Example: `PR_26171_063-*`. -+Current examples: -+- `PR_26171_ALPHA_065-message-studio-parent-child-table-foundation` -+- `PR_26171_BETA_069-message-tts-profile-contract-alignment` -+- `PR_26171_GAMMA_071-main-merge-conflict-recovery` -+- `pr/26171-ALPHA-065-message-studio-parent-child-table-foundation` -+- `pr/26171-BETA-069-message-tts-profile-contract-alignment` -+- `pr/26171-GAMMA-071-main-merge-conflict-recovery` -+ -+Historical examples: -+- Older PC/LAPTOP, desktop/laptop, workspace, environment, and machine-parity examples are historical only. -+- Older parity-only examples such as `PR_26171_064-*` and `PR_26171_063-*` are historical only. - - Owner map: - --Team Alpha / Environment 1 owns Creator Journey work: -+Team Alpha owns Creator Journey work: - - Game Journey - - Game Hub - - Idea -@@ -483,7 +495,7 @@ Team Alpha / Environment 1 owns Creator Journey work: - - Game Design - - Game Crew - --Team Beta / Environment 2 owns Content Creation and asset/publishing work: -+Team Beta owns Content Creation and asset/publishing work: - - Graphics - - Toolbox images - - Audio -@@ -496,17 +508,29 @@ Team Beta / Environment 2 owns Content Creation and asset/publishing work: - - Community - - Arcade - -+Team Gamma owns governance, recovery, diagnostics, and instruction-hardening work: -+- PR naming governance -+- Git workflow governance -+- Recovery reports -+- Workspace recovery -+- Main merge conflict recovery -+- Diagnostics -+- Instruction enforcement -+- Static docs governance -+ - Governance, recovery, diagnostics, and instruction-hardening PRs: --- Follow PR number parity unless Master Control explicitly assigns an owner. -+- Use the TEAM token assigned by Master Control. - - Must not implement tool/runtime work from the opposite owner. --- Must document owner/parity compliance in the PR report. -+- Must document TEAM ownership compliance in the PR report. -+- Recovery reports must include TEAM ownership. - - Stable and merge approval: --- Stable promotion and merge approval are controlled by the assigned Team Alpha or Team Beta owner. -+- Stable promotion and merge approval are controlled by the assigned Team Alpha, Team Beta, or Team Gamma owner. - - Master Control may recommend sequencing, but Codex must not merge or mark stable without explicit owner approval for the affected workstream. - - Hard stop rules: --- If the PR number parity does not match the current machine, stop before changes. --- If the PR scope belongs to the other machine owner, stop before changes. --- If the PR crosses Team Alpha and Team Beta ownership, stop and require Master Control to split or assign the work. -+- If the PR name does not include a TEAM token, stop before changes. -+- If the TEAM token does not match the owner map or explicit Master Control assignment, stop before changes. -+- If the PR scope belongs to another TEAM owner, stop before changes. -+- If the PR crosses multiple team ownership areas, stop and require Master Control to split or assign the work. - - If the requested implementation path conflicts with the active owner path, stop before changes. -diff --git a/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-manual-validation-notes.md -new file mode 100644 -index 000000000..bde715f6b ---- /dev/null -+++ b/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-manual-validation-notes.md -@@ -0,0 +1,17 @@ -+# PR_26171_ALPHA_075-team-based-pr-naming Manual Validation Notes +# PR_26171_ALPHA_046-game-hub-table-standard-rebuild + +## Instruction Compliance +- PASS: `docs_build/dev/PROJECT_INSTRUCTIONS.md` was read before implementation. +- PASS: `docs_build/dev/PROJECT_MULTI_PC.txt` was read before implementation. +- PASS: Start gate completed on clean latest `main` before branch creation. +- PASS: Team Alpha ownership verified for Game Hub / Creator Journey. +- PASS: Merge is blocked pending explicit Team Alpha owner approval. + +## Git Workflow +- Current branch: `pr/26171-ALPHA-046-game-hub-table-standard-rebuild` +- Created branch: `pr/26171-ALPHA-046-game-hub-table-standard-rebuild` +- Push result: pending +- PR URL: pending +- Merge approval status: pending explicit Team Alpha owner approval + +## Requirement PASS Evidence +- PASS: Game Hub center surface is a Projects table with columns Project, Description, Status, Updated, Journey, Actions. +- PASS: Project cell owns expansion with inline reusable chevron and `data-table-parent-cell`. +- PASS: Journey count is informational; Playwright verifies clicking it does not collapse the expanded project. +- PASS: Default Game Hub route renders all projects collapsed; handoff route expands the opened project to show source data. +- PASS: Only one project row expands at a time; Playwright verifies switching from Demo Game to Gravity Demo collapses the first row. +- PASS: Expanded child content renders directly under the owning project row using `.table-child-surface` and `.table-child-row`. +- PASS: No detached Project Information, Source Idea card, Game Foundation, Readiness Output, selected project panel, or technical ID display remains in the Game Hub center surface. +- PASS: Source Idea child section is read-only and shows Idea, Pitch, Notes. +- PASS: Game Journey Items render from Source Idea notes with item text, disabled check state, status text, and Open Journey actions. +- PASS: Idea Board-created project handoff path is supported through the existing `game` query string and repository `sourceIdea` contract. +- PASS: Empty Source Idea/Journey states use creator-safe language. +- PASS: Reusable Theme V2 table parent/child classes were added while preserving existing Idea Board aliases. +- PASS: No page-local CSS, inline styles, style blocks, inline event handlers, real DB persistence, or unrelated tool runtime changes were introduced. + +## Validation +- PASS: `node --check toolbox/game-workspace/game-workspace.js` +- PASS: `node --check tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs` +- PASS: `git diff --check` +- PASS: `npx playwright test tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs --project=playwright --workers=1 --reporter=line` (12 passed) +- PASS: `npm run test:workspace-v2` (RootToolsFutureState workspace-contract lane, 5 passed) +- NOT RUN: Targeted Idea Board Playwright; Idea Board handoff code was not changed in this PR. + +## Diff Stat +``` + assets/theme-v2/css/tables.css | 18 + + .../tools/GameWorkspaceMockRepository.spec.mjs | 378 +++++++---- + toolbox/game-workspace/game-workspace.js | 745 ++++++++++++--------- + toolbox/game-workspace/index.html | 149 ++--- + 4 files changed, 719 insertions(+), 571 deletions(-) +``` + +## Code Diff +```diff +diff --git a/assets/theme-v2/css/tables.css b/assets/theme-v2/css/tables.css +index c39ef19f0..aa98eea80 100644 +--- a/assets/theme-v2/css/tables.css ++++ b/assets/theme-v2/css/tables.css +@@ -111,10 +111,17 @@ td { + text-transform: none + } + ++.data-table [data-table-parent-cell], + .data-table [data-idea-board-idea-cell] { + cursor: pointer + } + ++.data-table [data-table-parent-cell]:focus-visible { ++ outline: var(--border-width-sm) solid var(--gold); ++ outline-offset: calc(var(--space-2) * -1) ++} ++ ++.table-parent-label, + .idea-board-idea-label { + display: inline-flex; + align-items: center; +@@ -126,10 +133,12 @@ td { + white-space: nowrap + } + ++.table-parent-label__text, + .idea-board-idea-label__text { + line-height: var(--line-height-single) + } + ++.table-parent-chevron, + .idea-board-idea-chevron { + display: inline-block; + width: 1em; +@@ -144,26 +153,35 @@ td { + mask-size: contain + } + ++.table-parent-chevron--down, + .idea-board-idea-chevron--down { + -webkit-mask-image: url("../images/gfs-chevron-down.svg"); + mask-image: url("../images/gfs-chevron-down.svg") + } + ++.table-parent-chevron--up, + .idea-board-idea-chevron--up { + -webkit-mask-image: url("../images/gfs-chevron-up.svg"); + mask-image: url("../images/gfs-chevron-up.svg") + } + ++.table-child-surface, + .idea-board-notes-child-surface { + margin-left: calc(var(--space-14) * 2); + max-width: calc(100% - (var(--space-14) * 2)) + } + ++.table-child-actions, + .idea-board-notes-child-actions { + padding-left: var(--space-14); + padding-top: var(--space-10) + } + ++.table-child-row > td:first-child, ++.table-child-indent { ++ padding-left: calc(var(--space-14) * 2) ++} ++ + .tool-form-table { + table-layout: fixed; + width: 100% +diff --git a/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs b/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs +index 1c1f742a9..82a90953a 100644 +--- a/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs ++++ b/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs +@@ -2,6 +2,16 @@ + import http from "node:http"; + import process from "node:process"; + import { MOCK_DB_KEYS } from "../../../src/dev-runtime/persistence/mock-db-store.js"; ++import { ++ TOOL_RELEASE_CHANNEL_HELP_TEXT, ++ TOOL_RELEASE_CHANNEL_LABELS, ++ TOOL_RELEASE_CHANNELS, ++ TOOL_STATUS_MODEL, ++ getActiveToolRegistry, ++ getToolProgressReadiness, ++ getToolRegistry, ++ getToolReleaseChannel, ++} from "../../../toolbox/toolRegistry.js"; + import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; + import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; + import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; +@@ -15,6 +25,65 @@ const SUPABASE_ENV_KEYS = Object.freeze([ + let fakeSupabaseServer; + let previousSupabaseEnv; + ++const TOOLBOX_DEFAULT_RELEASE_CHANNELS = Object.freeze(["wireframe", "beta", "complete"]); ++const BUILD_PATH_DEFAULT_RELEASE_CHANNELS = Object.freeze(["complete"]); ++const TOOLBOX_RELEASE_CHANNEL_SWATCHES = Object.freeze({ ++ beta: "swatch-gold", ++ complete: "swatch-green", ++ deprecated: "swatch-purple", ++ planned: "swatch-gray", ++ wireframe: "swatch-blue", ++}); ++const TOOLBOX_ROLE_FOCUS_TOOLS = Object.freeze({ ++ "Audio Creator": Object.freeze(["Audio", "Music", "Voices", "MIDI", "Audio Effects", "Voice Capture", "Text To Speech", "Assets"]), ++ Artist: Object.freeze(["Assets", "Colors", "Tags", "Fonts", "Sprites", "Characters", "Objects", "Animations"]), ++ Designer: Object.freeze(["Game Hub", "Game Journey", "Game Design", "Game Configuration", "Objects", "Worlds", "Characters", "Colors", "Assets", "Tags"]), ++ Owner: null, ++ Publisher: Object.freeze(["Publish", "Marketplace", "Community", "Cloud", "Languages"]), ++ Tester: Object.freeze(["Game Testing", "Controls", "Hitboxes", "Debug", "Performance", "Events"]), ++ Translator: Object.freeze(["Languages", "Voices", "Voice Capture", "Text To Speech"]), ++ Viewer: Object.freeze(["Game Hub", "Game Journey", "Game Design", "Game Configuration", "Objects", "Worlds", "Assets", "Colors", "Tags", "Audio", "Publish", "Marketplace", "Community", "Languages", "Achievements", "Ratings"]), ++ "World Builder": Object.freeze(["Worlds", "Objects", "Assets", "Colors", "Tags", "Animations"]), ++}); ++ ++function orderedUniqueValues(rows, accessor) { ++ return [...new Set(rows.map(accessor).filter(Boolean))]; ++} ++ ++function localToolboxContract(activeTools) { ++ return { ++ defaultReleaseChannels: { ++ buildPath: [...BUILD_PATH_DEFAULT_RELEASE_CHANNELS], ++ toolbox: [...TOOLBOX_DEFAULT_RELEASE_CHANNELS], ++ }, ++ groupSwatches: {}, ++ groups: orderedUniqueValues(activeTools, (tool) => tool.category || tool.group), ++ releaseChannelByStatus: Object.fromEntries(TOOL_STATUS_MODEL.map((status) => [ ++ status, ++ getToolReleaseChannel({ status }), ++ ])), ++ releaseChannelHelpText: { ...TOOL_RELEASE_CHANNEL_HELP_TEXT }, ++ releaseChannelLabels: { ...TOOL_RELEASE_CHANNEL_LABELS }, ++ releaseChannelSwatches: { ...TOOLBOX_RELEASE_CHANNEL_SWATCHES }, ++ releaseChannels: [...TOOL_RELEASE_CHANNELS], ++ roleFocusTools: { ...TOOLBOX_ROLE_FOCUS_TOOLS }, ++ toolboxGroupOrder: orderedUniqueValues(activeTools, (tool) => tool.toolboxGroup), ++ }; ++} ++ ++function localRegistrySnapshot() { ++ const activeTools = getActiveToolRegistry(); ++ return { ++ activeTools, ++ readinessByStatus: Object.fromEntries(TOOL_STATUS_MODEL.map((status) => [ ++ status, ++ getToolProgressReadiness(status), ++ ])), ++ toolboxContract: localToolboxContract(activeTools), ++ tools: getToolRegistry(), ++ }; ++} ++ + function restoreEnvValue(key, value) { + if (value === undefined) { + delete process.env[key]; +@@ -183,31 +252,33 @@ async function openRepoPage(page, pathName, options = {}) { + }); + } + +- if (pathName.includes("/toolbox/game-workspace/") || pathName.includes("/toolbox/project-workspace/")) { +- await page.route("**/api/platform-settings/banner", async (route) => { +- await route.fulfill({ +- contentType: "application/json", +- body: JSON.stringify({ +- data: { banner: { active: false, message: "", tone: "info" } }, +- ok: true, +- }), +- }); ++ await page.route("**/api/platform-settings/banner", async (route) => { ++ await route.fulfill({ ++ contentType: "application/json", ++ body: JSON.stringify({ ++ data: { banner: { active: false, message: "", tone: "info" } }, ++ ok: true, ++ }), + }); +- await page.route("**/api/toolbox/registry/snapshot", async (route) => { +- await route.fulfill({ +- contentType: "application/json", +- body: JSON.stringify({ +- data: { +- activeTools: [], +- readinessByStatus: {}, +- tools: [], +- toolboxContract: {}, +- }, +- ok: true, +- }), +- }); ++ }); ++ await page.route("**/api/toolbox/registry/snapshot", async (route) => { ++ await route.fulfill({ ++ contentType: "application/json", ++ body: JSON.stringify({ ++ data: localRegistrySnapshot(), ++ ok: true, ++ }), + }); +- } ++ }); ++ await page.route("**/api/toolbox/votes/snapshot", async (route) => { ++ await route.fulfill({ ++ contentType: "application/json", ++ body: JSON.stringify({ ++ data: { rows: [] }, ++ ok: true, ++ }), ++ }); ++ }); + + await workspaceV2CoverageReporter.start(page); + await page.goto(`${server.baseUrl}${pathName}`, { waitUntil: "networkidle" }); +@@ -243,52 +314,91 @@ test("Deprecated project workspace route points creators to Game Hub", async ({ + } + }); + +-test("Game Hub creates, opens, and deletes mock games", async ({ page }) => { ++test("Game Hub renders a table-first project accordion", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); + + try { + await expect(page.locator(".tool-workspace")).toBeVisible(); + await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); +- await expect(page.getByRole("button", { name: "Create Game" })).toHaveClass("btn"); +- await expect(page.getByRole("button", { name: "Create Game" })).toBeEnabled(); +- await expect(page.getByRole("button", { name: "Delete Open Game" })).toHaveClass("btn"); +- await expect(page.getByRole("button", { name: "Delete Open Game" })).toBeEnabled(); +- await expect(page.locator("[data-project-record-status]")).toHaveText("Project Information loaded."); +- await expect(page.locator("[data-game-project-information]")).toContainText("Project Information"); +- await expect(page.locator("[data-project-records-table]")).toContainText("Demo Game"); +- await expect(page.locator("[data-source-idea-section]")).toContainText("No source idea yet"); ++ await expect(page.locator("[data-project-record-status]")).toHaveText("Projects loaded."); ++ await expect(page.getByRole("table", { name: "Projects" })).toBeVisible(); ++ await expect(page.getByRole("columnheader")).toHaveText([ ++ "Project", ++ "Description", ++ "Status", ++ "Updated", ++ "Journey", ++ "Actions" ++ ]); ++ await expect(page.locator("[data-game-project-information]")).toHaveCount(0); ++ await expect(page.locator("[data-project-records-table]")).toHaveCount(0); ++ await expect(page.locator("[data-game-workspace-foundation]")).toHaveCount(0); ++ await expect(page.locator("[data-game-output-panels]")).toHaveCount(0); + await expect(page.locator("[data-active-game-name]")).toHaveText("Demo Game"); +- await expect(page.locator("[data-active-game-purpose]")).toHaveText("Game"); ++ await expect(page.locator("[data-active-game-purpose]")).toHaveText("Game project"); ++ await expect(page.locator("[data-active-game-status]")).toHaveText("Under Construction"); + await expect(page.locator("[data-current-user-role]")).toHaveText("Owner"); +- await expect(page.locator("[data-game-list]")).toContainText("Demo Game"); +- 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-projects-table]")).toContainText("Demo Game"); ++ await expect(page.locator("[data-game-projects-table]")).toContainText("Gravity Demo"); ++ await expect(page.locator("[data-game-projects-table]")).toContainText("Collision Demo"); ++ await expect(page.locator("[data-game-projects-table]")).toContainText("Camera Follow Demo"); ++ await expect(page.locator("[data-game-project-expanded-row]")).toHaveCount(0); ++ + const demoGameRow = page.locator("[data-game-row='demo-game']"); +- await expect(demoGameRow.locator("> .status")).toHaveCount(0); +- 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 expect(demoGameRow.locator(".table-parent-chevron--down")).toHaveCount(1); ++ await expect(demoGameRow.locator("td").nth(4)).toHaveText("0 Items"); ++ await expect(demoGameRow.getByRole("button", { name: "Open Demo Game" })).toHaveClass(/primary/); ++ await expect(demoGameRow.getByRole("button", { name: "Open Demo Game" })).toHaveAttribute("aria-current", "true"); ++ ++ await page.getByRole("button", { name: "Demo Game", exact: true }).click(); ++ await expect(page.locator("[data-game-project-expanded-row='demo-game']")).toBeVisible(); ++ await expect(demoGameRow.locator(".table-parent-chevron--up")).toHaveCount(1); ++ await expect(page.locator("[data-game-project-child-surface] [data-source-idea-section]")).toContainText("No source idea yet"); ++ ++ await demoGameRow.getByText("0 Items").click(); ++ await expect(page.locator("[data-game-project-expanded-row='demo-game']")).toBeVisible(); + +- await page.getByLabel("Game Name").fill("Launch Test Game"); +- await page.getByRole("button", { name: "Create Game" }).click(); ++ await page.getByRole("button", { name: "Gravity Demo", exact: true }).click(); ++ await expect(page.locator("[data-game-project-expanded-row]")).toHaveCount(1); ++ await expect(page.locator("[data-game-project-expanded-row='gravity-demo']")).toBeVisible(); ++ await expect(page.locator("[data-game-project-expanded-row='demo-game']")).toHaveCount(0); ++ ++ await page.getByRole("button", { name: "Gravity Demo", exact: true }).click(); ++ await expect(page.locator("[data-game-project-expanded-row]")).toHaveCount(0); ++ ++ await expectNoPageFailures(failures); ++ } finally { ++ await failures.server.close(); ++ } ++}); ++ ++test("Game Hub creates, opens, and deletes projects from table actions", async ({ page }) => { ++ const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); ++ ++ try { ++ await expect(page.getByRole("button", { name: "Create Project" })).toHaveClass("btn"); ++ await expect(page.getByRole("button", { name: "Create Project" })).toBeEnabled(); ++ ++ await page.getByLabel("Project Name").fill("Launch Test Game"); ++ await page.getByRole("button", { name: "Create Project" }).click(); + await expect(page.locator("[data-active-game-name]")).toHaveText("Launch Test Game"); +- await expect(page.locator("[data-game-list]")).toContainText("Launch Test Game"); +- await expect(page.locator("[data-game-project-information]")).toContainText("Launch Test Game"); +- await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game (Active)" })).toHaveClass(/primary/); ++ await expect(page.locator("[data-game-projects-table]")).toContainText("Launch Test Game"); ++ await expect(page.locator("[data-game-project-expanded-row='launch-test-game-1']")).toBeVisible(); ++ await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game" })).toHaveClass(/primary/); + await expect(page.locator("[data-game-workspace-log]")).toHaveText("Created and opened Launch Test Game."); + +- await page.getByLabel("Game Name").fill("Archive Game"); +- await page.getByRole("button", { name: "Create Game" }).click(); ++ await page.getByLabel("Project Name").fill("Archive Game"); ++ await page.getByRole("button", { name: "Create Project" }).click(); + await expect(page.locator("[data-active-game-name]")).toHaveText("Archive Game"); + +- await page.getByRole("button", { name: "Open Launch Test Game" }).click(); ++ await page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game" }).click(); + await expect(page.locator("[data-active-game-name]")).toHaveText("Launch Test Game"); +- await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game (Active)" })).toHaveAttribute("data-game-active", "true"); ++ await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game" })).toHaveAttribute("data-game-active", "true"); + await expect(page.locator("[data-game-workspace-log]")).toHaveText("Opened Launch Test Game."); + +- await page.getByRole("button", { name: "Delete Open Game" }).click(); ++ await page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Delete Launch Test Game" }).click(); + await expect(page.locator("[data-active-game-name]")).not.toHaveText("Launch Test Game"); +- await expect(page.locator("[data-game-list]")).not.toContainText("Launch Test Game"); ++ await expect(page.locator("[data-game-projects-table]")).not.toContainText("Launch Test Game"); + await expect(page.locator("[data-game-workspace-log]")).toHaveText("Deleted Launch Test Game."); + + await expectNoPageFailures(failures); +@@ -302,17 +412,16 @@ test("Game Hub preserves guest browsing and blocks guest saves", async ({ page } + + try { + await expect(page.locator("[data-active-game-name]")).toHaveText("Demo Game"); +- await expect(page.locator("[data-game-list]")).toContainText("Gravity Demo"); +- await expect(page.locator("[data-project-record-status]")).toHaveText("Project Information loaded. Sign in to save changes."); +- await expect(page.locator("[data-project-records-table]")).toContainText("Demo Game"); +- await expect(page.getByRole("button", { name: "Create Game" })).toBeDisabled(); +- await expect(page.getByRole("button", { name: "Delete Open Game" })).toBeDisabled(); +- await expect(page.getByLabel("Game Name")).toBeDisabled(); +- await expect(page.getByLabel("Game Purpose")).toBeDisabled(); +- await expect(page.getByLabel("Game Status")).toBeDisabled(); +- await expect(page.getByLabel("Current User Role")).toBeDisabled(); +- +- await page.getByRole("button", { name: "Open Gravity Demo" }).click(); ++ await expect(page.locator("[data-game-projects-table]")).toContainText("Gravity Demo"); ++ await expect(page.locator("[data-project-record-status]")).toHaveText("Projects loaded. Sign in to create or update projects."); ++ await expect(page.getByRole("button", { name: "Create Project" })).toBeDisabled(); ++ await expect(page.locator("[data-game-row='demo-game']").getByRole("button", { name: "Delete Demo Game" })).toBeDisabled(); ++ await expect(page.getByLabel("Project Name")).toBeDisabled(); ++ await expect(page.getByLabel("Project Type")).toBeDisabled(); ++ await expect(page.getByLabel("Project Status")).toBeDisabled(); ++ await expect(page.getByLabel("Change Role")).toBeDisabled(); ++ ++ await page.locator("[data-game-row='gravity-demo']").getByRole("button", { name: "Open Gravity Demo" }).click(); + await expect(page.locator("[data-active-game-name]")).toHaveText("Gravity Demo"); + await expect(page.locator("[data-game-workspace-log]")).toHaveText("Sign in to create or update Game Hub projects."); + +@@ -338,8 +447,9 @@ test("Game Hub shows active-game errors without throwing", async ({ page }) => { + + try { + expect(failures.failedRequests.some((request) => request.includes("502") && request.includes("/methods/getActiveGame"))).toBe(true); +- await expect(page.locator("[data-active-game-name]")).toHaveText("No game open"); +- await expect(page.locator("[data-game-workspace-log]")).toContainText("Active game is temporarily unavailable."); ++ await expect(page.locator("[data-active-game-name]")).toHaveCount(0); ++ await expect(page.locator("[data-game-projects-table]")).toContainText("Demo Game"); ++ await expect(page.locator("[data-game-workspace-log]")).toContainText("Active project is temporarily unavailable."); + expect(failures.pageErrors).toEqual([]); + expect(failures.consoleErrors.filter((message) => !message.includes("status of 502"))).toEqual([]); + } finally { +@@ -367,10 +477,10 @@ test("Game Hub reports malformed active-game payloads without throwing", async ( + const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html"); + + try { +- await expect(page.locator("[data-active-game-name]")).toHaveText("No game open"); ++ await expect(page.locator("[data-active-game-name]")).toHaveCount(0); + await expect(page.locator("[data-current-user-role]")).toHaveText("Viewer"); +- await expect(page.locator("[data-game-workspace-log]")).toContainText("Active game is temporarily unavailable."); +- await expect(page.getByLabel("Game Purpose")).toBeDisabled(); ++ await expect(page.locator("[data-game-workspace-log]")).toContainText("Active project is temporarily unavailable."); ++ await expect(page.getByLabel("Project Type")).toBeDisabled(); + + await expectNoPageFailures(failures); + } finally { +@@ -378,7 +488,7 @@ test("Game Hub reports malformed active-game payloads without throwing", async ( + } + }); + +-test("Game Hub displays and edits game purpose and member role", async ({ page }) => { ++test("Game Hub keeps project setup and role controls creator-facing", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); + + try { +@@ -405,29 +515,23 @@ test("Game Hub displays and edits game purpose and member role", async ({ page } + "Publisher", + "Viewer" + ]); +- await expect(page.getByLabel("Game Purpose")).toHaveValue("Game"); +- await expect(page.getByLabel("Game Status")).toHaveValue("Under Construction"); +- await expect(page.getByLabel("Current User Role")).toHaveValue("Owner"); +- +- await page.getByLabel("Game Purpose").selectOption("Learning Game"); +- await expect(page.locator("[data-active-game-purpose]")).toHaveText("Learning Game"); +- await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated Demo Game purpose to Learning Game."); +- +- await page.getByLabel("Game Status").selectOption("Ready for Testing"); +- await expect(page.locator("[data-active-game-status]")).toHaveText("Ready for Testing"); +- await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated Demo Game status to Ready for Testing."); ++ await expect(page.getByLabel("Project Type")).toHaveValue("Game"); ++ await expect(page.getByLabel("Project Status")).toHaveValue("Under Construction"); ++ await expect(page.getByLabel("Change Role")).toHaveValue("Owner"); + +- await page.getByLabel("Current User Role").selectOption("Designer"); ++ await page.getByLabel("Change Role").selectOption("Designer"); + await expect(page.locator("[data-current-user-role]")).toHaveText("Designer"); +- await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated current user role to Designer."); ++ await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated current role to Designer."); + +- await page.getByLabel("Game Purpose").selectOption("Capability Demo"); +- await page.getByLabel("Game Name").fill("Purpose Review Game"); +- await page.getByRole("button", { name: "Create Game" }).click(); ++ await page.getByLabel("Project Type").selectOption("Capability Demo"); ++ await page.getByLabel("Project Status").selectOption("Ready for Testing"); ++ await page.getByLabel("Project Name").fill("Purpose Review Game"); ++ await page.getByRole("button", { name: "Create Project" }).click(); + await expect(page.locator("[data-active-game-name]")).toHaveText("Purpose Review Game"); +- await expect(page.locator("[data-active-game-purpose]")).toHaveText("Capability Demo"); ++ await expect(page.locator("[data-active-game-purpose]")).toHaveText("Capability Demo project"); ++ await expect(page.locator("[data-active-game-status]")).toHaveText("Ready for Testing"); + await expect(page.locator("[data-current-user-role]")).toHaveText("Owner"); +- await expect(page.locator("[data-game-list]")).toContainText("Purpose Review Game"); ++ await expect(page.locator("[data-game-projects-table]")).toContainText("Purpose Review Game"); + + await expectNoPageFailures(failures); + } finally { +@@ -435,46 +539,66 @@ test("Game Hub displays and edits game purpose and member role", async ({ page } + } + }); + +-test("Game Hub progress panels update from mock game state", async ({ page }) => { +- const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); ++test("Game Hub opens handoff projects with Source Idea and Journey child rows", async ({ page }) => { ++ const handoffGame = { ++ id: "sky-orchard-1", ++ members: [ ++ { ++ displayName: "User 1", ++ gameId: "sky-orchard-1", ++ permission: "Owner", ++ role: "Owner", ++ userKey: MOCK_DB_KEYS.users.user1, ++ }, ++ ], ++ name: "Sky Orchard", ++ ownerDisplayName: "User 1", ++ ownerKey: MOCK_DB_KEYS.users.user1, ++ purpose: "Game", ++ sourceIdea: { ++ idea: "Sky Orchard", ++ notes: [ ++ "Floating islands need a wind map.", ++ "Harvest routes should change with weather.", ++ ], ++ pitch: "Grow floating islands into a shared orchard.", ++ }, ++ status: "Under Construction", ++ }; + +- try { +- await expect(page.locator("[data-game-status]")).toHaveText("Under Construction"); +- await expect(page.locator("[data-game-progress]")).toHaveText("Demo Game identity ready"); +- await expect(page.locator("[data-publishing-progress]")).toHaveText("Publish blocked until configuration and required assets are ready"); +- await expect(page.locator("[data-current-focus]")).toHaveText("Complete Game Configuration"); +- await expect(page.locator("[data-recommended-next-tool]").first()).toHaveText("Game Configuration"); +- await expect(page.locator("[data-game-progress-checklist]")).toContainText("Game identity: Complete"); +- await expect(page.locator("[data-game-output-panels] summary")).toHaveText([ +- "Readiness Output" +- ]); +- await expect(page.locator("aside.tool-column").last().getByText("Readiness Output")).toHaveCount(0); +- const panelOrderIsCorrect = await page.locator(".tool-center-panel").evaluate((panel) => { +- const projectInformation = panel.querySelector("[data-game-project-information]"); +- const sourceIdea = panel.querySelector("[data-source-idea-section]"); +- const staticOverlay = panel.querySelector("[data-game-workspace-foundation]"); +- const outputPanels = panel.querySelector("[data-game-output-panels]"); +- return Boolean( +- projectInformation && +- sourceIdea && +- staticOverlay && +- outputPanels && +- (projectInformation.compareDocumentPosition(sourceIdea) & Node.DOCUMENT_POSITION_FOLLOWING) && +- (sourceIdea.compareDocumentPosition(staticOverlay) & Node.DOCUMENT_POSITION_FOLLOWING) && +- (staticOverlay.compareDocumentPosition(outputPanels) & Node.DOCUMENT_POSITION_FOLLOWING) +- ); ++ await page.route("**/api/toolbox/game-workspace/repositories/*/methods/getActiveGame", async (route) => { ++ await route.fulfill({ ++ body: JSON.stringify({ data: { result: handoffGame }, ok: true }), ++ contentType: "application/json; charset=utf-8", ++ }); ++ }); ++ await page.route("**/api/toolbox/game-workspace/repositories/*/methods/openGame", async (route) => { ++ await route.fulfill({ ++ body: JSON.stringify({ data: { result: handoffGame }, ok: true }), ++ contentType: "application/json; charset=utf-8", ++ }); ++ }); ++ await page.route("**/api/toolbox/game-workspace/repositories/*/methods/listGames", async (route) => { ++ await route.fulfill({ ++ body: JSON.stringify({ data: { result: [handoffGame] }, ok: true }), ++ contentType: "application/json; charset=utf-8", + }); +- expect(panelOrderIsCorrect).toBe(true); ++ }); + +- await page.getByLabel("Game Name").fill("Progress Review Game"); +- await page.getByRole("button", { name: "Create Game" }).click(); +- await expect(page.locator("[data-game-status]")).toHaveText("Under Construction"); +- await expect(page.locator("[data-game-progress]")).toHaveText("Progress Review Game identity ready"); +- await expect(page.locator("[data-game-project-information]")).toContainText("Progress Review Game"); ++ const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); + +- await page.getByRole("button", { name: "Delete Open Game" }).click(); +- await expect(page.locator("[data-active-game-name]")).toHaveText("Demo Game"); +- await expect(page.locator("[data-game-progress]")).toHaveText("Demo Game identity ready"); ++ try { ++ await page.goto(`${failures.server.baseUrl}/toolbox/game-workspace/index.html?game=sky-orchard-1`, { waitUntil: "networkidle" }); ++ await expect(page.locator("[data-active-game-name]")).toHaveText("Sky Orchard"); ++ await expect(page.locator("[data-active-game-purpose]")).toHaveText("Grow floating islands into a shared orchard."); ++ await expect(page.locator("[data-game-row='sky-orchard-1'] td").nth(4)).toHaveText("2 Items"); ++ await expect(page.locator("[data-game-project-expanded-row='sky-orchard-1']")).toBeVisible(); ++ await expect(page.locator("[data-source-idea-display]")).toHaveText("Sky Orchard"); ++ await expect(page.locator("[data-source-idea-pitch]")).toHaveText("Grow floating islands into a shared orchard."); ++ await expect(page.locator("[data-source-idea-notes]")).toContainText("Floating islands need a wind map."); ++ await expect(page.getByRole("table", { name: "Game Journey Items for Sky Orchard" })).toContainText("Harvest routes should change with weather."); ++ await expect(page.getByRole("table", { name: "Game Journey Items for Sky Orchard" })).toContainText("Planned"); ++ await expect(page.locator("[data-game-project-child-surface]")).toBeVisible(); + + await expectNoPageFailures(failures); + } finally { +@@ -609,7 +733,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro + const failures = await openRepoPage(page, "/toolbox/index.html"); + + try { +- await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 11/39"); ++ await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 14/42"); + await expect(page.locator("[data-toolbox-role-focus]")).toHaveCount(0); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Hub$/ }) })).toBeVisible(); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Journey$/ }) })).toBeVisible(); +@@ -620,7 +744,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro + + await page.goto(`${failures.server.baseUrl}/toolbox/index.html?memberRole=Designer`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-toolbox-role-focus='Designer']")).toBeVisible(); +- await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 7/39"); ++ await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 8/42"); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Hub$/ }) })).toBeVisible(); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Journey$/ }) })).toBeVisible(); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Design$/ }) })).toBeVisible(); +@@ -632,7 +756,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro + + await page.goto(`${failures.server.baseUrl}/toolbox/index.html?memberRole=Audio%20Creator`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-toolbox-role-focus='Audio Creator']")).toBeVisible(); +- await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 1/39"); ++ await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 1/42"); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Assets$/ }) })).toBeVisible(); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Audio$/ }) })).toHaveCount(0); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^MIDI$/ }) })).toHaveCount(0); +@@ -640,7 +764,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro + + await page.goto(`${failures.server.baseUrl}/toolbox/index.html?memberRole=Viewer`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-toolbox-role-focus='Viewer']")).toBeVisible(); +- await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 9/39"); ++ await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 10/42"); + await expect(page.getByText("Viewer focus shows preview-safe read-only tiles only.")).toBeVisible(); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Hub$/ }) })).toBeVisible(); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Journey$/ }) })).toBeVisible(); +@@ -649,7 +773,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Assets$/ }) })).toBeVisible(); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Debug$/ }) })).toHaveCount(0); + await page.goto(`${failures.server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); +- await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 11/39"); ++ await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 14/42"); + await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Cloud$/ }) })).toHaveCount(0); + + await expectNoPageFailures(failures); +diff --git a/toolbox/game-workspace/game-workspace.js b/toolbox/game-workspace/game-workspace.js +index 90aea08f8..779d5f448 100644 +--- a/toolbox/game-workspace/game-workspace.js ++++ b/toolbox/game-workspace/game-workspace.js +@@ -9,44 +9,22 @@ import { getSessionCurrent } from "../../src/api/session-api-client.js"; + const repository = createGameWorkspaceApiRepository(); + + const elements = { +- activeGameName: document.querySelector("[data-active-game-name]"), +- activeGameOwner: document.querySelector("[data-active-game-owner]"), +- activeGamePurpose: document.querySelector("[data-active-game-purpose]"), +- activeGameStatus: document.querySelector("[data-active-game-status]"), +- currentFocus: document.querySelector("[data-current-focus]"), + currentUserRole: document.querySelector("[data-current-user-role]"), + currentUserRoleInput: document.querySelector("[data-current-user-role-input]"), +- deleteOpenGame: document.querySelector("[data-game-delete-active]"), + form: document.querySelector("[data-game-form]"), +- membersTable: document.querySelector("[data-game-members-table]"), + nameInput: document.querySelector("[data-game-name-input]"), +- progressChecklist: document.querySelector("[data-game-progress-checklist]"), +- gameList: document.querySelector("[data-game-list]"), +- gameProgress: document.querySelector("[data-game-progress]"), +- gameJourneyLink: document.querySelector("[data-game-journey-link]"), + projectRecordStatus: document.querySelector("[data-project-record-status]"), +- projectRecordsTable: document.querySelector("[data-project-records-table]"), ++ projectsTable: document.querySelector("[data-game-projects-table]"), + purposeInput: document.querySelector("[data-game-purpose-input]"), +- sourceIdeaDisplay: document.querySelector("[data-source-idea-display]"), +- sourceIdeaName: document.querySelector("[data-source-idea-name]"), +- sourceIdeaNotes: document.querySelector("[data-source-idea-notes]"), +- sourceIdeaPitch: document.querySelector("[data-source-idea-pitch]"), +- gameStatus: document.querySelector("[data-game-status]"), +- gameStatusInput: document.querySelector("[data-game-status-input]"), +- publishingProgress: document.querySelector("[data-publishing-progress]"), +- recommendedNextTool: document.querySelectorAll("[data-recommended-next-tool]"), ++ statusInput: document.querySelector("[data-game-status-input]"), + statusLog: document.querySelector("[data-game-workspace-log]"), +- tableCounts: document.querySelector("[data-game-table-counts]"), + }; + +-function setText(element, value) { +- if (element && typeof element.forEach === "function" && !element.nodeType) { +- element.forEach((item) => { +- item.textContent = value; +- }); +- return; +- } ++const state = { ++ expandedGameId: "", ++}; + ++function setText(element, value) { + if (element) { + element.textContent = value; + } +@@ -56,6 +34,10 @@ function setStatusLog(message) { + setText(elements.statusLog, message); + } + ++function setProjectRecordStatus(message) { ++ setText(elements.projectRecordStatus, message); ++} ++ + function isRecord(value) { + return Boolean(value && typeof value === "object"); + } +@@ -64,23 +46,36 @@ function isRepositoryErrorResult(value) { + return isRecord(value) && value.error === true; + } + +-function repositoryErrorMessage(value, context) { ++function repositoryErrorMessage(context) { + return `${context} is temporarily unavailable. Refresh the page or try again shortly.`; + } + + function reportRepositoryError(value, context) { + if (isRepositoryErrorResult(value)) { +- setStatusLog(repositoryErrorMessage(value, context)); ++ setStatusLog(repositoryErrorMessage(context)); + return true; + } + return false; + } + ++function currentSessionUserKey() { ++ try { ++ const session = getSessionCurrent(); ++ return session?.authenticated && session.userKey ? session.userKey : ""; ++ } catch { ++ return ""; ++ } ++} + -+Generated: 2026-06-20T21:34:35.616Z -+ -+## TEAM Ownership -+ -+- TEAM owner: ALPHA -+ -+## Manual Review -+ -+- Confirmed PROJECT_INSTRUCTIONS.md documents the required TEAM token in PR names. -+- Confirmed PROJECT_INSTRUCTIONS.md documents branch names mirroring PR ownership. -+- Confirmed PROJECT_MULTI_PC.txt makes team ownership independent of machine, workspace, laptop, desktop, and environment. -+- Confirmed older PC/LAPTOP and parity examples are marked historical only. -+- Confirmed recovery reports are required to include TEAM ownership. -+ -+No browser or runtime validation was required because this PR is docs/static only. -diff --git a/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-validation.md b/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-validation.md -new file mode 100644 -index 000000000..bf7d709b2 ---- /dev/null -+++ b/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-validation.md -@@ -0,0 +1,34 @@ -+# PR_26171_ALPHA_075-team-based-pr-naming Validation -+ -+Generated: 2026-06-20T21:34:35.616Z -+ -+## TEAM Ownership -+ -+- TEAM owner: ALPHA -+- Validation scope: docs/static validation only -+ -+## Commands -+ -+```text -+git diff --check -+``` -+ -+## Results -+ -+- Initial docs diff check: PASS -+ -+Output: -+ -+```text -+warning: in the working copy of 'docs_build/dev/reports/codex_review.diff', LF will be replaced by CRLF the next time Git touches it -+``` -+ -+## Final Validation -+ -+- Final full diff check after report generation: PASS -+ -+Output: -+ -+```text -+(no output) -+``` -diff --git a/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming.md b/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming.md -new file mode 100644 -index 000000000..e96be762d ---- /dev/null -+++ b/docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming.md -@@ -0,0 +1,62 @@ -+# PR_26171_ALPHA_075-team-based-pr-naming -+ -+Generated: 2026-06-20T21:34:35.616Z -+ -+## Purpose -+ -+Replace machine-based PR naming examples with team-based ownership naming. -+ -+## TEAM Ownership -+ -+- TEAM owner: ALPHA -+- Ownership source: docs_build/dev/PROJECT_MULTI_PC.txt -+- Scope classification: static docs governance -+- Machine/workspace/environment ownership: not used for PR naming or branch naming -+ -+## Scope -+ -+Updated only: -+ -+- docs_build/dev/PROJECT_INSTRUCTIONS.md -+- docs_build/dev/PROJECT_MULTI_PC.txt -+ -+## Naming Contract -+ -+Authoritative PR format: -+ -+`PR___<###>-` -+ -+Authoritative branch format: -+ -+`pr/--<###>-` -+ -+Required examples are documented for ALPHA, BETA, and GAMMA. ++function projectRecordsSaveAllowed() { ++ return Boolean(currentSessionUserKey()); ++} + -+## Instruction Compliance + function activeGameMembers(activeGame) { + return Array.isArray(activeGame?.members) ? activeGame.members : []; + } + +-function normalizeActiveGame(value, context = "Active game") { ++function normalizeGame(value, context = "Project") { + if (reportRepositoryError(value, context)) { + return null; + } +@@ -88,86 +83,34 @@ function normalizeActiveGame(value, context = "Active game") { + return null; + } + if (!isRecord(value) || !Array.isArray(value.members)) { +- setStatusLog(`${context} is temporarily unavailable. Refresh the page or try again shortly.`); ++ setStatusLog(repositoryErrorMessage(context)); + return null; + } + return value; + } + +-function normalizeProgress(value) { +- if (reportRepositoryError(value, "Game progress")) { +- return { +- gameStatus: "No Game", +- gameProgress: "Progress is temporarily unavailable", +- publishingProgress: "Unavailable", +- currentFocus: "Refresh Game Hub", +- recommendedNextTool: "Game Hub", +- progressChecklist: [ +- { label: "Project information", status: "Unavailable" }, +- ], +- }; +- } +- if (!isRecord(value)) { +- setStatusLog("Game progress is temporarily unavailable. Refresh the page or try again shortly."); +- } +- return isRecord(value) ? value : { +- gameStatus: "No Game", +- gameProgress: "No active game", +- publishingProgress: "Not started", +- currentFocus: "Create a game", +- recommendedNextTool: "Game Hub", +- progressChecklist: [], +- }; +-} +- +-function currentSessionUserKey() { +- try { +- const session = getSessionCurrent(); +- return session?.authenticated && session.userKey ? session.userKey : ""; +- } catch { +- return ""; ++function normalizeGameList(value) { ++ if (Array.isArray(value)) { ++ return value.map((game) => normalizeGame(game)).filter(Boolean); + } ++ if (!reportRepositoryError(value, "Projects")) { ++ setStatusLog(repositoryErrorMessage("Projects")); ++ } ++ return []; + } + +-function projectRecordsSaveAllowed() { +- return Boolean(currentSessionUserKey()); +-} +- +-function setProjectRecordStatus(message) { +- setText(elements.projectRecordStatus, message); +-} +- +-function refreshSaveControls() { +- const saveAllowed = projectRecordsSaveAllowed(); +- [elements.nameInput, elements.purposeInput, elements.gameStatusInput, elements.currentUserRoleInput].forEach((control) => { +- if (control) { +- control.disabled = !saveAllowed; +- } +- }); +- const submitButton = elements.form?.querySelector("button[type='submit']"); +- if (submitButton) { +- submitButton.disabled = !saveAllowed; +- } +- if (elements.deleteOpenGame) { +- elements.deleteOpenGame.disabled = !saveAllowed; +- } +- if (!saveAllowed) { +- const currentStatus = String(elements.statusLog?.textContent || ""); +- if (!/failed|Sign in required|unavailable/i.test(currentStatus)) { +- setStatusLog("Sign in to create or update Game Hub projects."); +- } ++function currentGameUserKey(activeGame) { ++ const sessionUserKey = currentSessionUserKey(); ++ const members = activeGameMembers(activeGame); ++ if (sessionUserKey && (!activeGame || members.some((member) => member.userKey === sessionUserKey))) { ++ return sessionUserKey; + } ++ return activeGame?.ownerKey || members.find((member) => member.permission === "Owner")?.userKey || ""; + } + +-function ensureProjectRecordsSaveAllowed(action) { +- if (projectRecordsSaveAllowed()) { +- return true; +- } +- const message = `Sign in required to ${action} Game Hub projects.`; +- setStatusLog(message); +- setProjectRecordStatus(message); +- refreshSaveControls(); +- return false; ++function currentGameMember(activeGame) { ++ const userKey = currentGameUserKey(activeGame); ++ return activeGameMembers(activeGame).find((member) => member.userKey === userKey) || null; + } + + function populateSelect(select, options) { +@@ -184,264 +127,399 @@ function populateSelect(select, options) { + }); + } + +-function currentGameUserKey(activeGame) { +- const sessionUserKey = currentSessionUserKey(); +- const members = activeGameMembers(activeGame); +- if (sessionUserKey && (!activeGame || members.some((member) => member.userKey === sessionUserKey))) { +- return sessionUserKey; ++function sourceIdeaFor(game) { ++ const sourceIdea = isRecord(game?.sourceIdea) ? game.sourceIdea : null; ++ const idea = String(sourceIdea?.idea || "").trim(); ++ const pitch = String(sourceIdea?.pitch || "").trim(); ++ const notes = Array.isArray(sourceIdea?.notes) ++ ? sourceIdea.notes.map((note) => String(note || "").trim()).filter(Boolean) ++ : []; ++ ++ return { idea, notes, pitch }; ++} ++ ++function projectDescription(game) { ++ const sourceIdea = sourceIdeaFor(game); ++ if (sourceIdea.pitch) { ++ return sourceIdea.pitch; + } +- return activeGame?.ownerKey || members.find((member) => member.permission === "Owner")?.userKey || ""; ++ return `${game?.purpose || "Game"} project`; + } + +-function currentGameMember(activeGame) { +- const userKey = currentGameUserKey(activeGame); +- return activeGameMembers(activeGame).find((member) => member.userKey === userKey) || null; ++function projectUpdated(game) { ++ const updated = String(game?.updated || game?.updatedAt || game?.lastUpdated || "").trim(); ++ if (updated) { ++ return updated; ++ } ++ return new Date().toISOString().slice(0, 10); ++} ++ ++function journeyItemsFor(game) { ++ return sourceIdeaFor(game).notes.map((note) => ({ ++ status: "Planned", ++ text: note, ++ })); + } + +-function createGameButton(game, isActive) { ++function journeyCountLabel(game) { ++ const count = journeyItemsFor(game).length; ++ return `${count} ${count === 1 ? "Item" : "Items"}`; ++} ++ ++function createCell(text, datasetName) { ++ const cell = document.createElement("td"); ++ cell.textContent = text; ++ if (datasetName) { ++ cell.dataset[datasetName] = "true"; ++ } ++ return cell; ++} ++ ++function createProjectCell(game, expanded, active) { ++ const cell = document.createElement("td"); ++ cell.dataset.gameToggle = game.id; ++ cell.dataset.tableParentCell = "true"; ++ cell.setAttribute("aria-expanded", expanded ? "true" : "false"); ++ cell.setAttribute("role", "button"); ++ cell.tabIndex = 0; ++ ++ const label = document.createElement("span"); ++ label.className = "table-parent-label"; ++ ++ const chevron = document.createElement("span"); ++ chevron.className = `table-parent-chevron table-parent-chevron--${expanded ? "up" : "down"}`; ++ chevron.setAttribute("aria-hidden", "true"); ++ ++ const text = document.createElement("span"); ++ text.className = "table-parent-label__text"; ++ text.textContent = game.name; ++ if (active) { ++ text.dataset.activeGameName = "true"; ++ } ++ ++ label.append(chevron, text); ++ cell.append(label); ++ return cell; ++} ++ ++function createOpenButton(game, active) { + const button = document.createElement("button"); +- button.className = isActive ? "btn primary" : "btn"; ++ button.className = active ? "btn primary" : "btn"; + button.type = "button"; + button.dataset.gameOpen = game.id; +- if (isActive) { ++ button.textContent = "Open"; ++ button.setAttribute("aria-label", `Open ${game.name}`); ++ if (active) { + button.dataset.gameActive = "true"; + button.setAttribute("aria-current", "true"); + } +- button.textContent = isActive ? `Open ${game.name} (Active)` : `Open ${game.name}`; + return button; + } + +-function renderProjectInformation(activeGame, currentMember, progress) { +- if (!elements.projectRecordsTable) { +- return; +- } ++function createDeleteButton(game) { ++ const button = document.createElement("button"); ++ button.className = "btn"; ++ button.type = "button"; ++ button.dataset.gameDelete = game.id; ++ button.textContent = "Delete"; ++ button.disabled = !projectRecordsSaveAllowed(); ++ button.setAttribute("aria-label", `Delete ${game.name}`); ++ return button; ++} + +- elements.projectRecordsTable.replaceChildren(); +- const row = document.createElement("tr"); +- [ +- { datasetName: "activeGameName", value: activeGame?.name || "No game open" }, +- { datasetName: "activeGameStatus", value: activeGame?.status || progress?.gameStatus || "No Game" }, +- { datasetName: "activeGamePurpose", value: activeGame?.purpose || "No purpose" }, +- { datasetName: "activeGameOwner", value: activeGame?.ownerDisplayName || "No owner" }, +- { datasetName: "currentUserRole", value: currentMember?.role || "Viewer" }, +- { datasetName: "recommendedNextTool", value: progress?.recommendedNextTool || "Game Hub" }, +- ].forEach(({ datasetName, value }) => { +- const cell = document.createElement("td"); +- cell.dataset[datasetName] = "true"; +- cell.textContent = value; +- row.append(cell); +- }); +- elements.projectRecordsTable.append(row); ++function createJourneyLink(game, compact = false) { ++ const link = document.createElement("a"); ++ link.className = compact ? "btn btn--compact" : "btn"; ++ link.href = `toolbox/game-journey/index.html?game=${encodeURIComponent(game.id)}`; ++ link.dataset.gameJourneyLink = "true"; ++ link.textContent = compact ? "Open" : "Open Journey"; ++ link.setAttribute("aria-label", `Open Game Journey for ${game.name}`); ++ return link; ++} + +- setProjectRecordStatus(projectRecordsSaveAllowed() +- ? "Project Information loaded." +- : "Project Information loaded. Sign in to save changes."); ++function createActionCell(game, active) { ++ const cell = document.createElement("td"); ++ const group = document.createElement("div"); ++ group.className = "action-group"; ++ group.append(createOpenButton(game, active), createJourneyLink(game), createDeleteButton(game)); ++ cell.append(group); ++ return cell; + } + +-function renderGameList() { +- if (!elements.gameList) { +- return; +- } ++function appendSourceIdeaSection(surface, game) { ++ const sourceIdea = sourceIdeaFor(game); ++ const section = document.createElement("section"); ++ section.className = "content-stack content-stack--compact"; ++ section.dataset.sourceIdeaSection = "true"; ++ section.setAttribute("aria-label", "Source Idea"); + +- const activeGame = normalizeActiveGame(repository.getActiveGame()); +- const gameUserKey = currentGameUserKey(activeGame); +- const listResult = repository.listGames(gameUserKey ? { userKey: gameUserKey } : {}); +- const games = Array.isArray(listResult) ? listResult : []; +- if (!Array.isArray(listResult) && !reportRepositoryError(listResult, "Game list")) { +- setStatusLog("Game list is temporarily unavailable. Refresh the page or try again shortly."); +- } ++ const heading = document.createElement("h3"); ++ heading.dataset.sourceIdeaName = "true"; ++ heading.textContent = "Source Idea"; + +- elements.gameList.replaceChildren(); ++ const wrapper = document.createElement("div"); ++ wrapper.className = "table-wrapper"; + +- if (games.length === 0) { +- const emptyState = document.createElement("p"); +- emptyState.className = "status"; +- emptyState.textContent = "No games. Create a game to continue."; +- elements.gameList.append(emptyState); +- return; +- } ++ const table = document.createElement("table"); ++ table.className = "data-table"; ++ table.setAttribute("aria-label", `Source Idea for ${game.name}`); + +- games.forEach((game) => { +- const row = document.createElement("article"); +- row.className = "callout"; +- row.dataset.gameRow = game.id; ++ const body = document.createElement("tbody"); ++ ++ const rows = [ ++ ["Idea", sourceIdea.idea || "No source idea yet", "sourceIdeaDisplay"], ++ ["Pitch", sourceIdea.pitch || "Create a project from Idea Board to see source details.", "sourceIdeaPitch"], ++ ]; ++ ++ rows.forEach(([label, value, datasetName]) => { ++ const row = document.createElement("tr"); ++ const header = document.createElement("th"); ++ header.scope = "row"; ++ header.textContent = label; ++ const cell = createCell(value, datasetName); ++ row.append(header, cell); ++ body.append(row); ++ }); ++ ++ const notesRow = document.createElement("tr"); ++ const notesHeader = document.createElement("th"); ++ notesHeader.scope = "row"; ++ notesHeader.textContent = "Notes"; ++ const notesCell = document.createElement("td"); ++ const notesList = document.createElement("ul"); ++ notesList.className = "content-list"; ++ notesList.dataset.sourceIdeaNotes = "true"; ++ const notes = sourceIdea.notes.length ? sourceIdea.notes : ["No source notes."]; ++ notes.forEach((note) => { ++ const item = document.createElement("li"); ++ item.textContent = note; ++ notesList.append(item); ++ }); ++ notesCell.append(notesList); ++ notesRow.append(notesHeader, notesCell); ++ body.append(notesRow); ++ ++ table.append(body); ++ wrapper.append(table); ++ section.append(heading, wrapper); ++ surface.append(section); ++} + +- const title = document.createElement("h4"); +- title.textContent = game.name; ++function appendJourneyItemsSection(surface, game) { ++ const section = document.createElement("section"); ++ section.className = "content-stack content-stack--compact"; ++ section.setAttribute("aria-label", "Game Journey Items"); + +- const meta = document.createElement("p"); +- meta.className = "eyebrow"; +- meta.textContent = `${game.purpose} | ${game.status} | ${game.ownerDisplayName}`; ++ const heading = document.createElement("h3"); ++ heading.textContent = "Game Journey Items"; + +- const isActive = activeGame?.id === game.id; +- const action = createGameButton(game, isActive); ++ const wrapper = document.createElement("div"); ++ wrapper.className = "table-wrapper"; + +- row.append(title, meta, action); ++ const table = document.createElement("table"); ++ table.className = "data-table"; ++ table.setAttribute("aria-label", `Game Journey Items for ${game.name}`); + +- elements.gameList.append(row); ++ const head = document.createElement("thead"); ++ const headRow = document.createElement("tr"); ++ ["Item", "Status", "Actions"].forEach((label) => { ++ const header = document.createElement("th"); ++ header.scope = "col"; ++ header.textContent = label; ++ headRow.append(header); + }); ++ head.append(headRow); ++ ++ const body = document.createElement("tbody"); ++ const items = journeyItemsFor(game); ++ if (!items.length) { ++ const emptyRow = document.createElement("tr"); ++ const emptyCell = document.createElement("td"); ++ emptyCell.colSpan = 3; ++ emptyCell.textContent = "No journey items yet."; ++ emptyRow.append(emptyCell); ++ body.append(emptyRow); ++ } else { ++ items.forEach((item) => { ++ const row = document.createElement("tr"); ++ row.className = "table-child-row"; ++ ++ const itemCell = createCell(item.text); ++ const statusCell = document.createElement("td"); ++ const checkboxLabel = document.createElement("label"); ++ checkboxLabel.className = "checkbox-label"; ++ const checkbox = document.createElement("input"); ++ checkbox.type = "checkbox"; ++ checkbox.disabled = true; ++ checkbox.setAttribute("aria-label", `${item.text} planned`); ++ const statusText = document.createElement("span"); ++ statusText.textContent = item.status; ++ checkboxLabel.append(checkbox, statusText); ++ statusCell.append(checkboxLabel); ++ ++ const actionsCell = document.createElement("td"); ++ actionsCell.append(createJourneyLink(game, true)); ++ ++ row.append(itemCell, statusCell, actionsCell); ++ body.append(row); ++ }); ++ } ++ ++ table.append(head, body); ++ wrapper.append(table); ++ section.append(heading, wrapper); ++ surface.append(section); + } + +-function renderMembersTable(activeGame) { +- if (!elements.membersTable) { +- return; +- } ++function createExpandedRow(game) { ++ const row = document.createElement("tr"); ++ row.dataset.gameProjectExpandedRow = game.id; ++ row.className = "table-child-row"; + +- elements.membersTable.replaceChildren(); ++ const cell = document.createElement("td"); ++ cell.colSpan = 6; + +- if (!activeGame) { +- const row = document.createElement("tr"); +- row.innerHTML = "No game---"; +- elements.membersTable.append(row); +- return; +- } ++ const surface = document.createElement("div"); ++ surface.className = "table-child-surface content-stack"; ++ surface.dataset.gameProjectChildSurface = "true"; + +- activeGameMembers(activeGame).forEach((member) => { +- const row = document.createElement("tr"); +- const name = document.createElement("td"); +- const userKey = document.createElement("td"); +- const role = document.createElement("td"); +- const permission = document.createElement("td"); +- +- name.textContent = member.displayName; +- userKey.textContent = member.userKey; +- role.textContent = member.role; +- permission.textContent = member.permission; +- +- row.append(name, userKey, role, permission); +- elements.membersTable.append(row); +- }); ++ appendSourceIdeaSection(surface, game); ++ appendJourneyItemsSection(surface, game); ++ ++ cell.append(surface); ++ row.append(cell); ++ return row; + } + +-function renderTableCounts() { +- if (!elements.tableCounts) { +- return; ++function renderProjectRow(game, activeGame) { ++ const active = activeGame?.id === game.id; ++ const expanded = state.expandedGameId === game.id; ++ const row = document.createElement("tr"); ++ row.dataset.gameRow = game.id; ++ if (active) { ++ row.dataset.gameActiveRow = "true"; ++ } ++ ++ const descriptionCell = createCell(projectDescription(game), active ? "activeGamePurpose" : ""); ++ const statusCell = createCell(game.status || "Planning", active ? "activeGameStatus" : ""); ++ const updatedCell = createCell(projectUpdated(game)); ++ const journeyCell = createCell(journeyCountLabel(game)); ++ ++ row.append( ++ createProjectCell(game, expanded, active), ++ descriptionCell, ++ statusCell, ++ updatedCell, ++ journeyCell, ++ createActionCell(game, active), ++ ); ++ ++ return [row, expanded ? createExpandedRow(game) : null].filter(Boolean); ++} ++ ++function renderProjectsTable() { ++ if (!elements.projectsTable) { ++ return null; + } + +- const tableResult = repository.getTables(); +- const tables = isRecord(tableResult) && !isRepositoryErrorResult(tableResult) +- ? tableResult +- : { users: [], games: [], game_members: [] }; +- if ((!isRecord(tableResult) || isRepositoryErrorResult(tableResult)) && !reportRepositoryError(tableResult, "Repository tables")) { +- setStatusLog("Game Hub project details are temporarily unavailable. Refresh the page or try again shortly."); ++ const activeGame = normalizeGame(repository.getActiveGame(), "Active project"); ++ const userKey = currentSessionUserKey(); ++ const listResult = repository.listGames(userKey ? { userKey } : {}); ++ const games = normalizeGameList(listResult); ++ ++ if (state.expandedGameId && !games.some((game) => game.id === state.expandedGameId)) { ++ state.expandedGameId = ""; + } +- const rows = [ +- ["users", Array.isArray(tables.users) ? tables.users.length : 0], +- ["games", Array.isArray(tables.games) ? tables.games.length : 0], +- ["game_members", Array.isArray(tables.game_members) ? tables.game_members.length : 0], +- ]; + +- elements.tableCounts.replaceChildren(); ++ elements.projectsTable.replaceChildren(); + +- rows.forEach(([tableName, count]) => { ++ if (!games.length) { + const row = document.createElement("tr"); +- const tableCell = document.createElement("td"); +- const countCell = document.createElement("td"); ++ const cell = document.createElement("td"); ++ cell.colSpan = 6; ++ cell.textContent = "No projects yet. Create a project to get started."; ++ row.append(cell); ++ elements.projectsTable.append(row); ++ } else { ++ games.flatMap((game) => renderProjectRow(game, activeGame)).forEach((row) => { ++ elements.projectsTable.append(row); ++ }); ++ } + +- tableCell.textContent = tableName; +- countCell.textContent = String(count); ++ setProjectRecordStatus(projectRecordsSaveAllowed() ++ ? "Projects loaded." ++ : "Projects loaded. Sign in to create or update projects."); + +- row.append(tableCell, countCell); +- elements.tableCounts.append(row); +- }); ++ return activeGame; + } + +-function renderSourceIdea(activeGame) { +- const sourceIdea = isRecord(activeGame?.sourceIdea) ? activeGame.sourceIdea : null; +- const name = String(sourceIdea?.idea || "").trim(); +- const pitch = String(sourceIdea?.pitch || "").trim(); +- const notes = Array.isArray(sourceIdea?.notes) +- ? sourceIdea.notes.map((note) => String(note || "").trim()).filter(Boolean) +- : []; ++function refreshSaveControls() { ++ const saveAllowed = projectRecordsSaveAllowed(); ++ [elements.nameInput, elements.purposeInput, elements.statusInput, elements.currentUserRoleInput].forEach((control) => { ++ if (control) { ++ control.disabled = !saveAllowed; ++ } ++ }); + +- setText(elements.sourceIdeaName, name || "No source idea yet"); +- setText(elements.sourceIdeaDisplay, name || "No source idea yet"); +- setText(elements.sourceIdeaPitch, pitch || "Create a project from Idea Board to see source details."); +- +- if (elements.sourceIdeaNotes) { +- elements.sourceIdeaNotes.replaceChildren(); +- const visibleNotes = notes.length ? notes : ["No source notes."]; +- visibleNotes.forEach((note) => { +- const item = document.createElement("li"); +- item.textContent = note; +- elements.sourceIdeaNotes.append(item); +- }); ++ const submitButton = elements.form?.querySelector("button[type='submit']"); ++ if (submitButton) { ++ submitButton.disabled = !saveAllowed; + } +-} + +-function renderChecklist(progress) { +- if (!elements.progressChecklist) { +- return; ++ if (!saveAllowed) { ++ const currentStatus = String(elements.statusLog?.textContent || ""); ++ if (!/failed|Sign in required|unavailable/i.test(currentStatus)) { ++ setStatusLog("Sign in to create or update Game Hub projects."); ++ } + } ++} + +- elements.progressChecklist.replaceChildren(); +- +- const checklist = Array.isArray(progress?.progressChecklist) ? progress.progressChecklist : []; +- checklist.forEach((item) => { +- const row = document.createElement("li"); +- row.textContent = `${item.label}: ${item.status}`; +- elements.progressChecklist.append(row); +- }); ++function ensureProjectRecordsSaveAllowed(action) { ++ if (projectRecordsSaveAllowed()) { ++ return true; ++ } ++ const message = `Sign in required to ${action} Game Hub projects.`; ++ setStatusLog(message); ++ setProjectRecordStatus(message); ++ refreshSaveControls(); ++ return false; + } + +-function renderWorkspace() { +- const activeGame = normalizeActiveGame(repository.getActiveGame()); +- const progress = normalizeProgress(repository.getGameProgress()); ++function renderRole(activeGame) { + const currentMember = currentGameMember(activeGame); +- +- setText(elements.activeGameName, activeGame?.name || "No game open"); +- setText(elements.activeGameOwner, activeGame?.ownerDisplayName || "No owner"); +- setText(elements.activeGamePurpose, activeGame?.purpose || "No purpose"); +- setText(elements.activeGameStatus, activeGame?.status || "No Game"); +- setText(elements.currentUserRole, currentMember?.role || "Viewer"); +- setText(elements.gameStatus, progress.gameStatus); +- setText(elements.gameProgress, progress.gameProgress); +- setText(elements.publishingProgress, progress.publishingProgress); +- setText(elements.currentFocus, progress.currentFocus); +- setText(elements.recommendedNextTool, progress.recommendedNextTool); +- if (elements.purposeInput && activeGame?.purpose) { +- elements.purposeInput.value = activeGame.purpose; +- } +- if (elements.gameStatusInput && activeGame?.status) { +- elements.gameStatusInput.value = activeGame.status; +- } ++ const role = currentMember?.role || "Viewer"; ++ setText(elements.currentUserRole, role); + if (elements.currentUserRoleInput) { +- elements.currentUserRoleInput.value = currentMember?.role || "Viewer"; +- } +- if (elements.gameJourneyLink) { +- if (activeGame) { +- elements.gameJourneyLink.href = `toolbox/game-journey/index.html?game=${encodeURIComponent(activeGame.id)}`; +- elements.gameJourneyLink.setAttribute("aria-disabled", "false"); +- } else { +- elements.gameJourneyLink.href = "toolbox/game-journey/index.html?game=none"; +- elements.gameJourneyLink.setAttribute("aria-disabled", "true"); +- } ++ elements.currentUserRoleInput.value = role; + } ++} + +- renderGameList(); +- renderMembersTable(activeGame); +- renderTableCounts(); +- renderChecklist(progress); +- renderProjectInformation(activeGame, currentMember, progress); +- renderSourceIdea(activeGame); ++function renderWorkspace() { ++ const activeGame = renderProjectsTable(); ++ renderRole(activeGame); + refreshSaveControls(); + } + ++function toggleProject(projectId) { ++ state.expandedGameId = state.expandedGameId === projectId ? "" : projectId; ++ renderWorkspace(); ++} ++ + elements.form?.addEventListener("submit", (event) => { + event.preventDefault(); + if (!ensureProjectRecordsSaveAllowed("create")) { + return; + } +- const activeGame = normalizeActiveGame(repository.getActiveGame()); ++ + const game = repository.createGame({ + name: elements.nameInput?.value, + purpose: elements.purposeInput?.value, +- status: elements.gameStatusInput?.value, ++ status: elements.statusInput?.value, + }); + +- if (reportRepositoryError(game, "Create Game") || !isRecord(game) || !String(game.name || "").trim()) { ++ if (reportRepositoryError(game, "Create Project") || !isRecord(game) || !String(game.name || "").trim()) { + if (!isRepositoryErrorResult(game)) { +- setStatusLog("Create Game could not be completed. Refresh the page or try again shortly."); ++ setStatusLog("Create Project could not be completed. Refresh the page or try again shortly."); + } + renderWorkspace(); + return; +@@ -450,97 +528,94 @@ elements.form?.addEventListener("submit", (event) => { + if (elements.nameInput) { + elements.nameInput.value = ""; + } +- ++ state.expandedGameId = game.id; + setStatusLog(`Created and opened ${game.name}.`); + renderWorkspace(); + }); + +-elements.gameList?.addEventListener("click", (event) => { +- const button = event.target.closest("[data-game-open]"); +- +- if (!button) { ++elements.projectsTable?.addEventListener("click", (event) => { ++ const toggleCell = event.target.closest("[data-game-toggle]"); ++ if (toggleCell && elements.projectsTable.contains(toggleCell)) { ++ toggleProject(toggleCell.dataset.gameToggle); + return; + } + +- const game = repository.openGame(button.dataset.gameOpen); +- +- if (game) { +- setStatusLog(`Opened ${game.name}.`); +- renderWorkspace(); +- } +-}); +- +-elements.deleteOpenGame?.addEventListener("click", () => { +- if (!ensureProjectRecordsSaveAllowed("delete")) { ++ const openButton = event.target.closest("[data-game-open]"); ++ if (openButton && elements.projectsTable.contains(openButton)) { ++ const game = repository.openGame(openButton.dataset.gameOpen); ++ if (reportRepositoryError(game, "Open Project")) { ++ renderWorkspace(); ++ return; ++ } ++ if (game) { ++ setStatusLog(`Opened ${game.name}.`); ++ renderWorkspace(); ++ } + return; + } +- const activeGame = normalizeActiveGame(repository.getActiveGame(), "Delete active game"); + +- if (!activeGame) { +- setStatusLog("No game is open for deletion."); ++ const deleteButton = event.target.closest("[data-game-delete]"); ++ if (deleteButton && elements.projectsTable.contains(deleteButton)) { ++ if (!ensureProjectRecordsSaveAllowed("delete")) { ++ return; ++ } ++ const gameId = deleteButton.dataset.gameDelete; ++ const gameName = deleteButton.getAttribute("aria-label")?.replace(/^Delete\s+/, "") || "project"; ++ repository.deleteGame(gameId); ++ if (state.expandedGameId === gameId) { ++ state.expandedGameId = ""; ++ } ++ setStatusLog(`Deleted ${gameName}.`); + renderWorkspace(); +- return; + } +- +- repository.deleteGame(activeGame.id); +- setStatusLog(`Deleted ${activeGame.name}.`); +- renderWorkspace(); + }); + +-elements.purposeInput?.addEventListener("change", () => { +- if (!ensureProjectRecordsSaveAllowed("update")) { +- return; +- } +- const activeGame = normalizeActiveGame(repository.getActiveGame(), "Update game purpose"); +- if (!activeGame) { ++elements.projectsTable?.addEventListener("keydown", (event) => { ++ if (event.key !== "Enter" && event.key !== " ") { + return; + } + +- const game = repository.updateGamePurpose(activeGame.id, elements.purposeInput.value); +- setStatusLog(`Updated ${game.name} purpose to ${game.purpose}.`); +- renderWorkspace(); +-}); +- +-elements.gameStatusInput?.addEventListener("change", () => { +- if (!ensureProjectRecordsSaveAllowed("update")) { +- return; +- } +- const activeGame = normalizeActiveGame(repository.getActiveGame(), "Update game status"); +- if (!activeGame) { ++ const toggleCell = event.target.closest("[data-game-toggle]"); ++ if (!toggleCell || !elements.projectsTable.contains(toggleCell)) { + return; + } + +- const game = repository.updateGameStatus(activeGame.id, elements.gameStatusInput.value); +- setStatusLog(`Updated ${game.name} status to ${game.status}.`); +- renderWorkspace(); ++ event.preventDefault(); ++ toggleProject(toggleCell.dataset.gameToggle); + }); + + elements.currentUserRoleInput?.addEventListener("change", () => { + if (!ensureProjectRecordsSaveAllowed("update")) { + return; + } +- const activeGame = normalizeActiveGame(repository.getActiveGame(), "Update current user role"); ++ const activeGame = normalizeGame(repository.getActiveGame(), "Update current role"); + if (!activeGame) { + return; + } + + repository.updateGameMemberRole(activeGame.id, currentGameUserKey(activeGame), elements.currentUserRoleInput.value); +- setStatusLog(`Updated current user role to ${elements.currentUserRoleInput.value}.`); ++ setStatusLog(`Updated current role to ${elements.currentUserRoleInput.value}.`); + renderWorkspace(); + }); + + populateSelect(elements.purposeInput, GAME_WORKSPACE_GAME_PURPOSES); +-populateSelect(elements.gameStatusInput, GAME_WORKSPACE_GAME_STATUSES); ++populateSelect(elements.statusInput, GAME_WORKSPACE_GAME_STATUSES); + populateSelect(elements.currentUserRoleInput, GAME_WORKSPACE_MEMBER_ROLES); ++if (elements.statusInput && GAME_WORKSPACE_GAME_STATUSES.includes("Under Construction")) { ++ elements.statusInput.value = "Under Construction"; ++} + -+- Read PROJECT_INSTRUCTIONS.md first: PASS -+- Read PROJECT_MULTI_PC.txt before edits: PASS -+- Started from main: PASS -+- Pulled latest main before branch: PASS -+- Clean repo before branch: PASS -+- Created branch: pr/26171-ALPHA-075-team-based-pr-naming -+- TEAM token required: PASS -+- TEAM ownership comes from PROJECT_MULTI_PC.txt: PASS -+- Future reports include TEAM ownership: PASS -+ -+## Git Workflow Status -+ -+- Starting HEAD: b31b319983dc62a240ab7095b8430b7c6ed182bd -+- origin/main at start: b31b319983dc62a240ab7095b8430b7c6ed182bd -+- origin/main...HEAD after branch creation: 0 0 -+- Push result: pending at report generation -+- PR URL: pending at report generation -+- Merge result: pending at report generation -+- Final main commit: pending at report generation -+ -+## Changed Files Before Report Generation + const requestedGameId = new URL(window.location.href).searchParams.get("game"); + if (requestedGameId) { + const openedGame = repository.openGame(requestedGameId); + if (isRepositoryErrorResult(openedGame)) { +- setStatusLog(repositoryErrorMessage(openedGame, "Open Game")); ++ setStatusLog(repositoryErrorMessage("Open Project")); + } else if (openedGame) { ++ state.expandedGameId = openedGame.id; + setStatusLog(`Opened ${openedGame.name}.`); + } else { + setStatusLog("That Game Hub project could not be found."); + } + } + -+```text -+docs_build/dev/PROJECT_INSTRUCTIONS.md -+docs_build/dev/PROJECT_MULTI_PC.txt -+``` -diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt -index 2f0436ffd..e067b2d67 100644 ---- a/docs_build/dev/reports/codex_changed_files.txt -+++ b/docs_build/dev/reports/codex_changed_files.txt -@@ -1,17 +1,7 @@ --A docs_build/dev/reports/PR_26171_069-manual-validation-notes.md --A docs_build/dev/reports/PR_26171_069-message-tts-ownership-checklist.md --A docs_build/dev/reports/PR_26171_069-message-tts-profile-contract-alignment.md --A docs_build/dev/reports/PR_26171_069-parent-child-table-checklist.md --A docs_build/dev/reports/PR_26171_069-validation.md --A docs_build/pr/PR_26171_069-message-tts-profile-contract-alignment/APPLY_PR.md --A docs_build/pr/PR_26171_069-message-tts-profile-contract-alignment/BUILD_PR.md --A docs_build/pr/PR_26171_069-message-tts-profile-contract-alignment/PLAN_PR.md --M tests/playwright/tools/MessagesTool.spec.mjs --M tests/playwright/tools/TextToSpeechFunctional.spec.mjs --M tests/tools/Text2SpeechShell.test.mjs --M toolbox/messages/index.html --M toolbox/messages/message-tts-service-registry.js --M toolbox/messages/messages.js --M toolbox/text-to-speech/text2speech.js --M docs_build/dev/reports/codex_review.diff --M docs_build/dev/reports/codex_changed_files.txt -+docs_build/dev/PROJECT_INSTRUCTIONS.md -+docs_build/dev/PROJECT_MULTI_PC.txt -+docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-manual-validation-notes.md -+docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming-validation.md -+docs_build/dev/reports/PR_26171_ALPHA_075-team-based-pr-naming.md -+docs_build/dev/reports/codex_changed_files.txt -+docs_build/dev/reports/codex_review.diff + renderWorkspace(); +diff --git a/toolbox/game-workspace/index.html b/toolbox/game-workspace/index.html +index f204166d8..afed04469 100644 +--- a/toolbox/game-workspace/index.html ++++ b/toolbox/game-workspace/index.html +@@ -6,7 +6,7 @@ + + + Game Hub - GameFoundryStudio +- ++ + + + +@@ -18,7 +18,7 @@ +
+
Toolbox / Game Hub
+

Game Hub

+-

Create, open, and delete games while reviewing progress and publish readiness.

++

Create, open, and review projects from one table.

+
+ +
+@@ -30,132 +30,57 @@ + +
+
+- Game Setup ++ Project Setup +
+
+
+- ++
+ + +- +- ++ ++ + + +- ++ + + + +- ++ + + + +
+
+- ++ +
+- +- +-
+-
+-
+- Open Games +-
+-
+
+
+
+ +
+-

Project Information

+-

Review the open project and its source idea.

+-
Project Information ready.
+-
+-
+-
+- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +-
Project Information
ProjectStatusPurposeOwnerRoleNext Tool
Demo GameUnder ConstructionGameNo ownerOwnerGame Configuration
+-
+- +-
+-
+-
+-
+-
+-
Source Idea
+-

No source idea yet

+-
+-
+- +- +- +- +- +- +-
IdeaNo source idea yet
PitchCreate a project from Idea Board to see source details.
Notes
  • No source notes.
+-
+-
+-
+-
+-
+-
+-
Game Foundation
+-

Game Progress

+-
+-
+-

Game Status

Under Construction

+-

Game Progress

Demo Game identity ready

+-

Launch Progress

Publish blocked until configuration and required assets are ready

+-
+-
+-

Current Focus

Complete Game Configuration

+-

Recommended Next Tool

Game Configuration

+-

Checklist

  • Game identity: Complete
+-
+-
Game Hub ready.
+-
+-
+-
+-
+- Readiness Output +-
+-
+- +- +- +- +- +- +- +- +- +- +-
Readiness output
PathStatusNext Tool
PlanUnder ConstructionGame Configuration
ConfigurePlannedBuild Game
ReleasePlannedPublish
+-
+-
+-
++
++

Projects

++

Expand a project to review its Source Idea and Game Journey Items.

++
Projects ready.
++
++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++
Projects
ProjectDescriptionStatusUpdatedJourneyActions
Loading projects.
+
+
+
+
++
++ Project Role ++
++

Current role: Viewer

++ ++
++
+
+ Status Log +
+-

Game Hub actions report status in the center panel.

+-
Game data ready.
++
Game Hub ready.
+
+
+
+``` diff --git a/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/APPLY_PR.md b/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/APPLY_PR.md new file mode 100644 index 000000000..1174fa897 --- /dev/null +++ b/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/APPLY_PR.md @@ -0,0 +1,19 @@ +# PR_26171_ALPHA_046-game-hub-table-standard-rebuild APPLY + +## Git Workflow +- Created branch: `pr/26171-ALPHA-046-game-hub-table-standard-rebuild` +- Push result: pending +- PR URL: pending +- Merge approval status: pending Team Alpha owner approval +- Merge result: not allowed without explicit Team Alpha owner approval + +## Validation +- `node --check toolbox/game-workspace/game-workspace.js`: pending +- `node --check tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs`: pending +- Targeted Game Hub Playwright: pending +- `npm run test:workspace-v2`: pending + +## ZIP +- Path: `tmp/PR_26171_ALPHA_046-game-hub-table-standard-rebuild_delta.zip` +- Size: pending +- Contents: pending diff --git a/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/BUILD_PR.md b/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/BUILD_PR.md new file mode 100644 index 000000000..8e5b4bcf6 --- /dev/null +++ b/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/BUILD_PR.md @@ -0,0 +1,53 @@ +# PR_26171_ALPHA_046-game-hub-table-standard-rebuild BUILD + +## Start Gate +- Read `docs_build/dev/PROJECT_INSTRUCTIONS.md`. +- Read `docs_build/dev/PROJECT_MULTI_PC.txt`. +- Confirm current branch starts from clean latest `main`. +- Confirm Team Alpha owns Game Hub / Creator Journey. +- Confirm merge requires explicit Team Alpha owner approval. + +## Exact Targets +- `toolbox/game-workspace/index.html` +- `toolbox/game-workspace/game-workspace.js` +- `assets/theme-v2/css/tables.css` +- `tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs` +- `docs_build/dev/reports/codex_review.diff` +- `docs_build/dev/reports/codex_changed_files.txt` + +## Required Implementation +- Primary Game Hub surface is a Projects table. +- Parent table columns: `Project`, `Description`, `Status`, `Updated`, `Journey`, `Actions`. +- Project cell owns expansion. +- Chevron is inside the Project cell, left of Project name. +- Entire Project cell toggles expansion. +- Journey count is informational only. +- Default state is all collapsed when no explicit project handoff is opened. +- Only one Project row may be expanded at a time. +- Expanded child content renders directly under owning Project row. +- No detached selected project panel. +- No detached context/detail panel. +- No visible technical IDs. +- Source Idea child section is read-only and shows `Idea`, `Pitch`, `Notes`. +- Game Journey Items are generated from Source Idea notes and show item text, status/check state, and applicable actions. +- Game Hub can open/render an Idea Board-created project when existing handoff data exists. +- Empty states are creator-safe. +- Copy avoids DB/API/mock/debug/seed/internal terminology. +- Styling uses reusable Theme V2 table child classes. +- No page-local CSS, inline styles, style blocks, or inline event handlers. + +## Required Validation +- `node --check toolbox/game-workspace/game-workspace.js` +- `node --check tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs` +- Targeted Game Hub Playwright validation. +- `npm run test:workspace-v2` +- Do not run full samples smoke. + +## Required Delivery +- Update required reports. +- Produce repo-structured ZIP under `tmp/PR_26171_ALPHA_046-game-hub-table-standard-rebuild_delta.zip`. +- Stage only scoped files. +- Commit. +- Push branch. +- Create PR. +- Stop before merge unless explicit Team Alpha owner approval is present. diff --git a/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/PLAN_PR.md b/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/PLAN_PR.md new file mode 100644 index 000000000..ed6e12255 --- /dev/null +++ b/docs_build/pr/PR_26171_ALPHA_046-game-hub-table-standard-rebuild/PLAN_PR.md @@ -0,0 +1,27 @@ +# PR_26171_ALPHA_046-game-hub-table-standard-rebuild PLAN + +## Source Of Truth +- `docs_build/dev/PROJECT_INSTRUCTIONS.md` +- `docs_build/dev/PROJECT_MULTI_PC.txt` +- User request: `PR_26171_ALPHA_046-game-hub-table-standard-rebuild` + +## Purpose +Rebuild Game Hub as a creator-facing, table-first Projects workspace that follows the approved parent/child accordion table pattern. + +## Ownership +- Team: Alpha +- Area: Game Hub / Creator Journey +- Merge gate: do not merge without explicit Team Alpha owner approval. + +## Scope +- Replace conflicting Game Hub card/summary/context structure with a Projects table. +- Render expanded project child content directly under its owning row. +- Use reusable Theme V2 table child classes. +- Preserve existing in-page/repository handoff path for Idea Board-created projects. +- Keep creator-facing copy free of technical implementation wording. + +## Non-Scope +- No real database persistence. +- No Postgres/API expansion. +- No unrelated tool changes. +- No merge without Team Alpha approval. diff --git a/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs b/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs index 1c1f742a9..82a90953a 100644 --- a/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs +++ b/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs @@ -2,6 +2,16 @@ import http from "node:http"; import process from "node:process"; import { MOCK_DB_KEYS } from "../../../src/dev-runtime/persistence/mock-db-store.js"; +import { + TOOL_RELEASE_CHANNEL_HELP_TEXT, + TOOL_RELEASE_CHANNEL_LABELS, + TOOL_RELEASE_CHANNELS, + TOOL_STATUS_MODEL, + getActiveToolRegistry, + getToolProgressReadiness, + getToolRegistry, + getToolReleaseChannel, +} from "../../../toolbox/toolRegistry.js"; import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; @@ -15,6 +25,65 @@ const SUPABASE_ENV_KEYS = Object.freeze([ let fakeSupabaseServer; let previousSupabaseEnv; +const TOOLBOX_DEFAULT_RELEASE_CHANNELS = Object.freeze(["wireframe", "beta", "complete"]); +const BUILD_PATH_DEFAULT_RELEASE_CHANNELS = Object.freeze(["complete"]); +const TOOLBOX_RELEASE_CHANNEL_SWATCHES = Object.freeze({ + beta: "swatch-gold", + complete: "swatch-green", + deprecated: "swatch-purple", + planned: "swatch-gray", + wireframe: "swatch-blue", +}); +const TOOLBOX_ROLE_FOCUS_TOOLS = Object.freeze({ + "Audio Creator": Object.freeze(["Audio", "Music", "Voices", "MIDI", "Audio Effects", "Voice Capture", "Text To Speech", "Assets"]), + Artist: Object.freeze(["Assets", "Colors", "Tags", "Fonts", "Sprites", "Characters", "Objects", "Animations"]), + Designer: Object.freeze(["Game Hub", "Game Journey", "Game Design", "Game Configuration", "Objects", "Worlds", "Characters", "Colors", "Assets", "Tags"]), + Owner: null, + Publisher: Object.freeze(["Publish", "Marketplace", "Community", "Cloud", "Languages"]), + Tester: Object.freeze(["Game Testing", "Controls", "Hitboxes", "Debug", "Performance", "Events"]), + Translator: Object.freeze(["Languages", "Voices", "Voice Capture", "Text To Speech"]), + Viewer: Object.freeze(["Game Hub", "Game Journey", "Game Design", "Game Configuration", "Objects", "Worlds", "Assets", "Colors", "Tags", "Audio", "Publish", "Marketplace", "Community", "Languages", "Achievements", "Ratings"]), + "World Builder": Object.freeze(["Worlds", "Objects", "Assets", "Colors", "Tags", "Animations"]), +}); + +function orderedUniqueValues(rows, accessor) { + return [...new Set(rows.map(accessor).filter(Boolean))]; +} + +function localToolboxContract(activeTools) { + return { + defaultReleaseChannels: { + buildPath: [...BUILD_PATH_DEFAULT_RELEASE_CHANNELS], + toolbox: [...TOOLBOX_DEFAULT_RELEASE_CHANNELS], + }, + groupSwatches: {}, + groups: orderedUniqueValues(activeTools, (tool) => tool.category || tool.group), + releaseChannelByStatus: Object.fromEntries(TOOL_STATUS_MODEL.map((status) => [ + status, + getToolReleaseChannel({ status }), + ])), + releaseChannelHelpText: { ...TOOL_RELEASE_CHANNEL_HELP_TEXT }, + releaseChannelLabels: { ...TOOL_RELEASE_CHANNEL_LABELS }, + releaseChannelSwatches: { ...TOOLBOX_RELEASE_CHANNEL_SWATCHES }, + releaseChannels: [...TOOL_RELEASE_CHANNELS], + roleFocusTools: { ...TOOLBOX_ROLE_FOCUS_TOOLS }, + toolboxGroupOrder: orderedUniqueValues(activeTools, (tool) => tool.toolboxGroup), + }; +} + +function localRegistrySnapshot() { + const activeTools = getActiveToolRegistry(); + return { + activeTools, + readinessByStatus: Object.fromEntries(TOOL_STATUS_MODEL.map((status) => [ + status, + getToolProgressReadiness(status), + ])), + toolboxContract: localToolboxContract(activeTools), + tools: getToolRegistry(), + }; +} + function restoreEnvValue(key, value) { if (value === undefined) { delete process.env[key]; @@ -183,31 +252,33 @@ async function openRepoPage(page, pathName, options = {}) { }); } - if (pathName.includes("/toolbox/game-workspace/") || pathName.includes("/toolbox/project-workspace/")) { - await page.route("**/api/platform-settings/banner", async (route) => { - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - data: { banner: { active: false, message: "", tone: "info" } }, - ok: true, - }), - }); + await page.route("**/api/platform-settings/banner", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: { banner: { active: false, message: "", tone: "info" } }, + ok: true, + }), }); - await page.route("**/api/toolbox/registry/snapshot", async (route) => { - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - data: { - activeTools: [], - readinessByStatus: {}, - tools: [], - toolboxContract: {}, - }, - ok: true, - }), - }); + }); + await page.route("**/api/toolbox/registry/snapshot", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: localRegistrySnapshot(), + ok: true, + }), }); - } + }); + await page.route("**/api/toolbox/votes/snapshot", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: { rows: [] }, + ok: true, + }), + }); + }); await workspaceV2CoverageReporter.start(page); await page.goto(`${server.baseUrl}${pathName}`, { waitUntil: "networkidle" }); @@ -243,52 +314,91 @@ test("Deprecated project workspace route points creators to Game Hub", async ({ } }); -test("Game Hub creates, opens, and deletes mock games", async ({ page }) => { +test("Game Hub renders a table-first project accordion", async ({ page }) => { const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); try { await expect(page.locator(".tool-workspace")).toBeVisible(); await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); - await expect(page.getByRole("button", { name: "Create Game" })).toHaveClass("btn"); - await expect(page.getByRole("button", { name: "Create Game" })).toBeEnabled(); - await expect(page.getByRole("button", { name: "Delete Open Game" })).toHaveClass("btn"); - await expect(page.getByRole("button", { name: "Delete Open Game" })).toBeEnabled(); - await expect(page.locator("[data-project-record-status]")).toHaveText("Project Information loaded."); - await expect(page.locator("[data-game-project-information]")).toContainText("Project Information"); - await expect(page.locator("[data-project-records-table]")).toContainText("Demo Game"); - await expect(page.locator("[data-source-idea-section]")).toContainText("No source idea yet"); + await expect(page.locator("[data-project-record-status]")).toHaveText("Projects loaded."); + await expect(page.getByRole("table", { name: "Projects" })).toBeVisible(); + await expect(page.getByRole("columnheader")).toHaveText([ + "Project", + "Description", + "Status", + "Updated", + "Journey", + "Actions" + ]); + await expect(page.locator("[data-game-project-information]")).toHaveCount(0); + await expect(page.locator("[data-project-records-table]")).toHaveCount(0); + await expect(page.locator("[data-game-workspace-foundation]")).toHaveCount(0); + await expect(page.locator("[data-game-output-panels]")).toHaveCount(0); await expect(page.locator("[data-active-game-name]")).toHaveText("Demo Game"); - await expect(page.locator("[data-active-game-purpose]")).toHaveText("Game"); + await expect(page.locator("[data-active-game-purpose]")).toHaveText("Game project"); + await expect(page.locator("[data-active-game-status]")).toHaveText("Under Construction"); await expect(page.locator("[data-current-user-role]")).toHaveText("Owner"); - await expect(page.locator("[data-game-list]")).toContainText("Demo Game"); - 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-projects-table]")).toContainText("Demo Game"); + await expect(page.locator("[data-game-projects-table]")).toContainText("Gravity Demo"); + await expect(page.locator("[data-game-projects-table]")).toContainText("Collision Demo"); + await expect(page.locator("[data-game-projects-table]")).toContainText("Camera Follow Demo"); + await expect(page.locator("[data-game-project-expanded-row]")).toHaveCount(0); + const demoGameRow = page.locator("[data-game-row='demo-game']"); - await expect(demoGameRow.locator("> .status")).toHaveCount(0); - 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 expect(demoGameRow.locator(".table-parent-chevron--down")).toHaveCount(1); + await expect(demoGameRow.locator("td").nth(4)).toHaveText("0 Items"); + await expect(demoGameRow.getByRole("button", { name: "Open Demo Game" })).toHaveClass(/primary/); + await expect(demoGameRow.getByRole("button", { name: "Open Demo Game" })).toHaveAttribute("aria-current", "true"); + + await page.getByRole("button", { name: "Demo Game", exact: true }).click(); + await expect(page.locator("[data-game-project-expanded-row='demo-game']")).toBeVisible(); + await expect(demoGameRow.locator(".table-parent-chevron--up")).toHaveCount(1); + await expect(page.locator("[data-game-project-child-surface] [data-source-idea-section]")).toContainText("No source idea yet"); + + await demoGameRow.getByText("0 Items").click(); + await expect(page.locator("[data-game-project-expanded-row='demo-game']")).toBeVisible(); - await page.getByLabel("Game Name").fill("Launch Test Game"); - await page.getByRole("button", { name: "Create Game" }).click(); + await page.getByRole("button", { name: "Gravity Demo", exact: true }).click(); + await expect(page.locator("[data-game-project-expanded-row]")).toHaveCount(1); + await expect(page.locator("[data-game-project-expanded-row='gravity-demo']")).toBeVisible(); + await expect(page.locator("[data-game-project-expanded-row='demo-game']")).toHaveCount(0); + + await page.getByRole("button", { name: "Gravity Demo", exact: true }).click(); + await expect(page.locator("[data-game-project-expanded-row]")).toHaveCount(0); + + await expectNoPageFailures(failures); + } finally { + await failures.server.close(); + } +}); + +test("Game Hub creates, opens, and deletes projects from table actions", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); + + try { + await expect(page.getByRole("button", { name: "Create Project" })).toHaveClass("btn"); + await expect(page.getByRole("button", { name: "Create Project" })).toBeEnabled(); + + await page.getByLabel("Project Name").fill("Launch Test Game"); + await page.getByRole("button", { name: "Create Project" }).click(); await expect(page.locator("[data-active-game-name]")).toHaveText("Launch Test Game"); - await expect(page.locator("[data-game-list]")).toContainText("Launch Test Game"); - await expect(page.locator("[data-game-project-information]")).toContainText("Launch Test Game"); - await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game (Active)" })).toHaveClass(/primary/); + await expect(page.locator("[data-game-projects-table]")).toContainText("Launch Test Game"); + await expect(page.locator("[data-game-project-expanded-row='launch-test-game-1']")).toBeVisible(); + await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game" })).toHaveClass(/primary/); await expect(page.locator("[data-game-workspace-log]")).toHaveText("Created and opened Launch Test Game."); - await page.getByLabel("Game Name").fill("Archive Game"); - await page.getByRole("button", { name: "Create Game" }).click(); + await page.getByLabel("Project Name").fill("Archive Game"); + await page.getByRole("button", { name: "Create Project" }).click(); await expect(page.locator("[data-active-game-name]")).toHaveText("Archive Game"); - await page.getByRole("button", { name: "Open Launch Test Game" }).click(); + await page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game" }).click(); await expect(page.locator("[data-active-game-name]")).toHaveText("Launch Test Game"); - await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game (Active)" })).toHaveAttribute("data-game-active", "true"); + await expect(page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Open Launch Test Game" })).toHaveAttribute("data-game-active", "true"); await expect(page.locator("[data-game-workspace-log]")).toHaveText("Opened Launch Test Game."); - await page.getByRole("button", { name: "Delete Open Game" }).click(); + await page.locator("[data-game-row='launch-test-game-1']").getByRole("button", { name: "Delete Launch Test Game" }).click(); await expect(page.locator("[data-active-game-name]")).not.toHaveText("Launch Test Game"); - await expect(page.locator("[data-game-list]")).not.toContainText("Launch Test Game"); + await expect(page.locator("[data-game-projects-table]")).not.toContainText("Launch Test Game"); await expect(page.locator("[data-game-workspace-log]")).toHaveText("Deleted Launch Test Game."); await expectNoPageFailures(failures); @@ -302,17 +412,16 @@ test("Game Hub preserves guest browsing and blocks guest saves", async ({ page } try { await expect(page.locator("[data-active-game-name]")).toHaveText("Demo Game"); - await expect(page.locator("[data-game-list]")).toContainText("Gravity Demo"); - await expect(page.locator("[data-project-record-status]")).toHaveText("Project Information loaded. Sign in to save changes."); - await expect(page.locator("[data-project-records-table]")).toContainText("Demo Game"); - await expect(page.getByRole("button", { name: "Create Game" })).toBeDisabled(); - await expect(page.getByRole("button", { name: "Delete Open Game" })).toBeDisabled(); - await expect(page.getByLabel("Game Name")).toBeDisabled(); - await expect(page.getByLabel("Game Purpose")).toBeDisabled(); - await expect(page.getByLabel("Game Status")).toBeDisabled(); - await expect(page.getByLabel("Current User Role")).toBeDisabled(); - - await page.getByRole("button", { name: "Open Gravity Demo" }).click(); + await expect(page.locator("[data-game-projects-table]")).toContainText("Gravity Demo"); + await expect(page.locator("[data-project-record-status]")).toHaveText("Projects loaded. Sign in to create or update projects."); + await expect(page.getByRole("button", { name: "Create Project" })).toBeDisabled(); + await expect(page.locator("[data-game-row='demo-game']").getByRole("button", { name: "Delete Demo Game" })).toBeDisabled(); + await expect(page.getByLabel("Project Name")).toBeDisabled(); + await expect(page.getByLabel("Project Type")).toBeDisabled(); + await expect(page.getByLabel("Project Status")).toBeDisabled(); + await expect(page.getByLabel("Change Role")).toBeDisabled(); + + await page.locator("[data-game-row='gravity-demo']").getByRole("button", { name: "Open Gravity Demo" }).click(); await expect(page.locator("[data-active-game-name]")).toHaveText("Gravity Demo"); await expect(page.locator("[data-game-workspace-log]")).toHaveText("Sign in to create or update Game Hub projects."); @@ -338,8 +447,9 @@ test("Game Hub shows active-game errors without throwing", async ({ page }) => { try { expect(failures.failedRequests.some((request) => request.includes("502") && request.includes("/methods/getActiveGame"))).toBe(true); - await expect(page.locator("[data-active-game-name]")).toHaveText("No game open"); - await expect(page.locator("[data-game-workspace-log]")).toContainText("Active game is temporarily unavailable."); + await expect(page.locator("[data-active-game-name]")).toHaveCount(0); + await expect(page.locator("[data-game-projects-table]")).toContainText("Demo Game"); + await expect(page.locator("[data-game-workspace-log]")).toContainText("Active project is temporarily unavailable."); expect(failures.pageErrors).toEqual([]); expect(failures.consoleErrors.filter((message) => !message.includes("status of 502"))).toEqual([]); } finally { @@ -367,10 +477,10 @@ test("Game Hub reports malformed active-game payloads without throwing", async ( const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html"); try { - await expect(page.locator("[data-active-game-name]")).toHaveText("No game open"); + await expect(page.locator("[data-active-game-name]")).toHaveCount(0); await expect(page.locator("[data-current-user-role]")).toHaveText("Viewer"); - await expect(page.locator("[data-game-workspace-log]")).toContainText("Active game is temporarily unavailable."); - await expect(page.getByLabel("Game Purpose")).toBeDisabled(); + await expect(page.locator("[data-game-workspace-log]")).toContainText("Active project is temporarily unavailable."); + await expect(page.getByLabel("Project Type")).toBeDisabled(); await expectNoPageFailures(failures); } finally { @@ -378,7 +488,7 @@ test("Game Hub reports malformed active-game payloads without throwing", async ( } }); -test("Game Hub displays and edits game purpose and member role", async ({ page }) => { +test("Game Hub keeps project setup and role controls creator-facing", async ({ page }) => { const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); try { @@ -405,29 +515,23 @@ test("Game Hub displays and edits game purpose and member role", async ({ page } "Publisher", "Viewer" ]); - await expect(page.getByLabel("Game Purpose")).toHaveValue("Game"); - await expect(page.getByLabel("Game Status")).toHaveValue("Under Construction"); - await expect(page.getByLabel("Current User Role")).toHaveValue("Owner"); - - await page.getByLabel("Game Purpose").selectOption("Learning Game"); - await expect(page.locator("[data-active-game-purpose]")).toHaveText("Learning Game"); - await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated Demo Game purpose to Learning Game."); - - await page.getByLabel("Game Status").selectOption("Ready for Testing"); - await expect(page.locator("[data-active-game-status]")).toHaveText("Ready for Testing"); - await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated Demo Game status to Ready for Testing."); + await expect(page.getByLabel("Project Type")).toHaveValue("Game"); + await expect(page.getByLabel("Project Status")).toHaveValue("Under Construction"); + await expect(page.getByLabel("Change Role")).toHaveValue("Owner"); - await page.getByLabel("Current User Role").selectOption("Designer"); + await page.getByLabel("Change Role").selectOption("Designer"); await expect(page.locator("[data-current-user-role]")).toHaveText("Designer"); - await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated current user role to Designer."); + await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated current role to Designer."); - await page.getByLabel("Game Purpose").selectOption("Capability Demo"); - await page.getByLabel("Game Name").fill("Purpose Review Game"); - await page.getByRole("button", { name: "Create Game" }).click(); + await page.getByLabel("Project Type").selectOption("Capability Demo"); + await page.getByLabel("Project Status").selectOption("Ready for Testing"); + await page.getByLabel("Project Name").fill("Purpose Review Game"); + await page.getByRole("button", { name: "Create Project" }).click(); await expect(page.locator("[data-active-game-name]")).toHaveText("Purpose Review Game"); - await expect(page.locator("[data-active-game-purpose]")).toHaveText("Capability Demo"); + await expect(page.locator("[data-active-game-purpose]")).toHaveText("Capability Demo project"); + await expect(page.locator("[data-active-game-status]")).toHaveText("Ready for Testing"); await expect(page.locator("[data-current-user-role]")).toHaveText("Owner"); - await expect(page.locator("[data-game-list]")).toContainText("Purpose Review Game"); + await expect(page.locator("[data-game-projects-table]")).toContainText("Purpose Review Game"); await expectNoPageFailures(failures); } finally { @@ -435,46 +539,66 @@ test("Game Hub displays and edits game purpose and member role", async ({ page } } }); -test("Game Hub progress panels update from mock game state", async ({ page }) => { - const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); +test("Game Hub opens handoff projects with Source Idea and Journey child rows", async ({ page }) => { + const handoffGame = { + id: "sky-orchard-1", + members: [ + { + displayName: "User 1", + gameId: "sky-orchard-1", + permission: "Owner", + role: "Owner", + userKey: MOCK_DB_KEYS.users.user1, + }, + ], + name: "Sky Orchard", + ownerDisplayName: "User 1", + ownerKey: MOCK_DB_KEYS.users.user1, + purpose: "Game", + sourceIdea: { + idea: "Sky Orchard", + notes: [ + "Floating islands need a wind map.", + "Harvest routes should change with weather.", + ], + pitch: "Grow floating islands into a shared orchard.", + }, + status: "Under Construction", + }; - try { - await expect(page.locator("[data-game-status]")).toHaveText("Under Construction"); - await expect(page.locator("[data-game-progress]")).toHaveText("Demo Game identity ready"); - await expect(page.locator("[data-publishing-progress]")).toHaveText("Publish blocked until configuration and required assets are ready"); - await expect(page.locator("[data-current-focus]")).toHaveText("Complete Game Configuration"); - await expect(page.locator("[data-recommended-next-tool]").first()).toHaveText("Game Configuration"); - await expect(page.locator("[data-game-progress-checklist]")).toContainText("Game identity: Complete"); - await expect(page.locator("[data-game-output-panels] summary")).toHaveText([ - "Readiness Output" - ]); - await expect(page.locator("aside.tool-column").last().getByText("Readiness Output")).toHaveCount(0); - const panelOrderIsCorrect = await page.locator(".tool-center-panel").evaluate((panel) => { - const projectInformation = panel.querySelector("[data-game-project-information]"); - const sourceIdea = panel.querySelector("[data-source-idea-section]"); - const staticOverlay = panel.querySelector("[data-game-workspace-foundation]"); - const outputPanels = panel.querySelector("[data-game-output-panels]"); - return Boolean( - projectInformation && - sourceIdea && - staticOverlay && - outputPanels && - (projectInformation.compareDocumentPosition(sourceIdea) & Node.DOCUMENT_POSITION_FOLLOWING) && - (sourceIdea.compareDocumentPosition(staticOverlay) & Node.DOCUMENT_POSITION_FOLLOWING) && - (staticOverlay.compareDocumentPosition(outputPanels) & Node.DOCUMENT_POSITION_FOLLOWING) - ); + await page.route("**/api/toolbox/game-workspace/repositories/*/methods/getActiveGame", async (route) => { + await route.fulfill({ + body: JSON.stringify({ data: { result: handoffGame }, ok: true }), + contentType: "application/json; charset=utf-8", + }); + }); + await page.route("**/api/toolbox/game-workspace/repositories/*/methods/openGame", async (route) => { + await route.fulfill({ + body: JSON.stringify({ data: { result: handoffGame }, ok: true }), + contentType: "application/json; charset=utf-8", + }); + }); + await page.route("**/api/toolbox/game-workspace/repositories/*/methods/listGames", async (route) => { + await route.fulfill({ + body: JSON.stringify({ data: { result: [handoffGame] }, ok: true }), + contentType: "application/json; charset=utf-8", }); - expect(panelOrderIsCorrect).toBe(true); + }); - await page.getByLabel("Game Name").fill("Progress Review Game"); - await page.getByRole("button", { name: "Create Game" }).click(); - await expect(page.locator("[data-game-status]")).toHaveText("Under Construction"); - await expect(page.locator("[data-game-progress]")).toHaveText("Progress Review Game identity ready"); - await expect(page.locator("[data-game-project-information]")).toContainText("Progress Review Game"); + const failures = await openRepoPage(page, "/toolbox/game-workspace/index.html", { session: creatorSession() }); - await page.getByRole("button", { name: "Delete Open Game" }).click(); - await expect(page.locator("[data-active-game-name]")).toHaveText("Demo Game"); - await expect(page.locator("[data-game-progress]")).toHaveText("Demo Game identity ready"); + try { + await page.goto(`${failures.server.baseUrl}/toolbox/game-workspace/index.html?game=sky-orchard-1`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-active-game-name]")).toHaveText("Sky Orchard"); + await expect(page.locator("[data-active-game-purpose]")).toHaveText("Grow floating islands into a shared orchard."); + await expect(page.locator("[data-game-row='sky-orchard-1'] td").nth(4)).toHaveText("2 Items"); + await expect(page.locator("[data-game-project-expanded-row='sky-orchard-1']")).toBeVisible(); + await expect(page.locator("[data-source-idea-display]")).toHaveText("Sky Orchard"); + await expect(page.locator("[data-source-idea-pitch]")).toHaveText("Grow floating islands into a shared orchard."); + await expect(page.locator("[data-source-idea-notes]")).toContainText("Floating islands need a wind map."); + await expect(page.getByRole("table", { name: "Game Journey Items for Sky Orchard" })).toContainText("Harvest routes should change with weather."); + await expect(page.getByRole("table", { name: "Game Journey Items for Sky Orchard" })).toContainText("Planned"); + await expect(page.locator("[data-game-project-child-surface]")).toBeVisible(); await expectNoPageFailures(failures); } finally { @@ -609,7 +733,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro const failures = await openRepoPage(page, "/toolbox/index.html"); try { - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 11/39"); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 14/42"); await expect(page.locator("[data-toolbox-role-focus]")).toHaveCount(0); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Hub$/ }) })).toBeVisible(); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Journey$/ }) })).toBeVisible(); @@ -620,7 +744,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro await page.goto(`${failures.server.baseUrl}/toolbox/index.html?memberRole=Designer`, { waitUntil: "networkidle" }); await expect(page.locator("[data-toolbox-role-focus='Designer']")).toBeVisible(); - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 7/39"); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 8/42"); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Hub$/ }) })).toBeVisible(); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Journey$/ }) })).toBeVisible(); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Design$/ }) })).toBeVisible(); @@ -632,7 +756,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro await page.goto(`${failures.server.baseUrl}/toolbox/index.html?memberRole=Audio%20Creator`, { waitUntil: "networkidle" }); await expect(page.locator("[data-toolbox-role-focus='Audio Creator']")).toBeVisible(); - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 1/39"); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 1/42"); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Assets$/ }) })).toBeVisible(); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Audio$/ }) })).toHaveCount(0); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^MIDI$/ }) })).toHaveCount(0); @@ -640,7 +764,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro await page.goto(`${failures.server.baseUrl}/toolbox/index.html?memberRole=Viewer`, { waitUntil: "networkidle" }); await expect(page.locator("[data-toolbox-role-focus='Viewer']")).toBeVisible(); - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 9/39"); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 10/42"); await expect(page.getByText("Viewer focus shows preview-safe read-only tiles only.")).toBeVisible(); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Hub$/ }) })).toBeVisible(); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Game Journey$/ }) })).toBeVisible(); @@ -649,7 +773,7 @@ test("Toolbox member-role filters focus tools without exposing admin-only contro await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Assets$/ }) })).toBeVisible(); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Debug$/ }) })).toHaveCount(0); await page.goto(`${failures.server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 11/39"); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 14/42"); await expect(page.locator("main .control-card").filter({ has: page.locator("h3", { hasText: /^Cloud$/ }) })).toHaveCount(0); await expectNoPageFailures(failures); diff --git a/toolbox/game-workspace/game-workspace.js b/toolbox/game-workspace/game-workspace.js index 90aea08f8..779d5f448 100644 --- a/toolbox/game-workspace/game-workspace.js +++ b/toolbox/game-workspace/game-workspace.js @@ -9,44 +9,22 @@ import { getSessionCurrent } from "../../src/api/session-api-client.js"; const repository = createGameWorkspaceApiRepository(); const elements = { - activeGameName: document.querySelector("[data-active-game-name]"), - activeGameOwner: document.querySelector("[data-active-game-owner]"), - activeGamePurpose: document.querySelector("[data-active-game-purpose]"), - activeGameStatus: document.querySelector("[data-active-game-status]"), - currentFocus: document.querySelector("[data-current-focus]"), currentUserRole: document.querySelector("[data-current-user-role]"), currentUserRoleInput: document.querySelector("[data-current-user-role-input]"), - deleteOpenGame: document.querySelector("[data-game-delete-active]"), form: document.querySelector("[data-game-form]"), - membersTable: document.querySelector("[data-game-members-table]"), nameInput: document.querySelector("[data-game-name-input]"), - progressChecklist: document.querySelector("[data-game-progress-checklist]"), - gameList: document.querySelector("[data-game-list]"), - gameProgress: document.querySelector("[data-game-progress]"), - gameJourneyLink: document.querySelector("[data-game-journey-link]"), projectRecordStatus: document.querySelector("[data-project-record-status]"), - projectRecordsTable: document.querySelector("[data-project-records-table]"), + projectsTable: document.querySelector("[data-game-projects-table]"), purposeInput: document.querySelector("[data-game-purpose-input]"), - sourceIdeaDisplay: document.querySelector("[data-source-idea-display]"), - sourceIdeaName: document.querySelector("[data-source-idea-name]"), - sourceIdeaNotes: document.querySelector("[data-source-idea-notes]"), - sourceIdeaPitch: document.querySelector("[data-source-idea-pitch]"), - gameStatus: document.querySelector("[data-game-status]"), - gameStatusInput: document.querySelector("[data-game-status-input]"), - publishingProgress: document.querySelector("[data-publishing-progress]"), - recommendedNextTool: document.querySelectorAll("[data-recommended-next-tool]"), + statusInput: document.querySelector("[data-game-status-input]"), statusLog: document.querySelector("[data-game-workspace-log]"), - tableCounts: document.querySelector("[data-game-table-counts]"), }; -function setText(element, value) { - if (element && typeof element.forEach === "function" && !element.nodeType) { - element.forEach((item) => { - item.textContent = value; - }); - return; - } +const state = { + expandedGameId: "", +}; +function setText(element, value) { if (element) { element.textContent = value; } @@ -56,6 +34,10 @@ function setStatusLog(message) { setText(elements.statusLog, message); } +function setProjectRecordStatus(message) { + setText(elements.projectRecordStatus, message); +} + function isRecord(value) { return Boolean(value && typeof value === "object"); } @@ -64,23 +46,36 @@ function isRepositoryErrorResult(value) { return isRecord(value) && value.error === true; } -function repositoryErrorMessage(value, context) { +function repositoryErrorMessage(context) { return `${context} is temporarily unavailable. Refresh the page or try again shortly.`; } function reportRepositoryError(value, context) { if (isRepositoryErrorResult(value)) { - setStatusLog(repositoryErrorMessage(value, context)); + setStatusLog(repositoryErrorMessage(context)); return true; } return false; } +function currentSessionUserKey() { + try { + const session = getSessionCurrent(); + return session?.authenticated && session.userKey ? session.userKey : ""; + } catch { + return ""; + } +} + +function projectRecordsSaveAllowed() { + return Boolean(currentSessionUserKey()); +} + function activeGameMembers(activeGame) { return Array.isArray(activeGame?.members) ? activeGame.members : []; } -function normalizeActiveGame(value, context = "Active game") { +function normalizeGame(value, context = "Project") { if (reportRepositoryError(value, context)) { return null; } @@ -88,86 +83,34 @@ function normalizeActiveGame(value, context = "Active game") { return null; } if (!isRecord(value) || !Array.isArray(value.members)) { - setStatusLog(`${context} is temporarily unavailable. Refresh the page or try again shortly.`); + setStatusLog(repositoryErrorMessage(context)); return null; } return value; } -function normalizeProgress(value) { - if (reportRepositoryError(value, "Game progress")) { - return { - gameStatus: "No Game", - gameProgress: "Progress is temporarily unavailable", - publishingProgress: "Unavailable", - currentFocus: "Refresh Game Hub", - recommendedNextTool: "Game Hub", - progressChecklist: [ - { label: "Project information", status: "Unavailable" }, - ], - }; - } - if (!isRecord(value)) { - setStatusLog("Game progress is temporarily unavailable. Refresh the page or try again shortly."); - } - return isRecord(value) ? value : { - gameStatus: "No Game", - gameProgress: "No active game", - publishingProgress: "Not started", - currentFocus: "Create a game", - recommendedNextTool: "Game Hub", - progressChecklist: [], - }; -} - -function currentSessionUserKey() { - try { - const session = getSessionCurrent(); - return session?.authenticated && session.userKey ? session.userKey : ""; - } catch { - return ""; +function normalizeGameList(value) { + if (Array.isArray(value)) { + return value.map((game) => normalizeGame(game)).filter(Boolean); } + if (!reportRepositoryError(value, "Projects")) { + setStatusLog(repositoryErrorMessage("Projects")); + } + return []; } -function projectRecordsSaveAllowed() { - return Boolean(currentSessionUserKey()); -} - -function setProjectRecordStatus(message) { - setText(elements.projectRecordStatus, message); -} - -function refreshSaveControls() { - const saveAllowed = projectRecordsSaveAllowed(); - [elements.nameInput, elements.purposeInput, elements.gameStatusInput, elements.currentUserRoleInput].forEach((control) => { - if (control) { - control.disabled = !saveAllowed; - } - }); - const submitButton = elements.form?.querySelector("button[type='submit']"); - if (submitButton) { - submitButton.disabled = !saveAllowed; - } - if (elements.deleteOpenGame) { - elements.deleteOpenGame.disabled = !saveAllowed; - } - if (!saveAllowed) { - const currentStatus = String(elements.statusLog?.textContent || ""); - if (!/failed|Sign in required|unavailable/i.test(currentStatus)) { - setStatusLog("Sign in to create or update Game Hub projects."); - } +function currentGameUserKey(activeGame) { + const sessionUserKey = currentSessionUserKey(); + const members = activeGameMembers(activeGame); + if (sessionUserKey && (!activeGame || members.some((member) => member.userKey === sessionUserKey))) { + return sessionUserKey; } + return activeGame?.ownerKey || members.find((member) => member.permission === "Owner")?.userKey || ""; } -function ensureProjectRecordsSaveAllowed(action) { - if (projectRecordsSaveAllowed()) { - return true; - } - const message = `Sign in required to ${action} Game Hub projects.`; - setStatusLog(message); - setProjectRecordStatus(message); - refreshSaveControls(); - return false; +function currentGameMember(activeGame) { + const userKey = currentGameUserKey(activeGame); + return activeGameMembers(activeGame).find((member) => member.userKey === userKey) || null; } function populateSelect(select, options) { @@ -184,264 +127,399 @@ function populateSelect(select, options) { }); } -function currentGameUserKey(activeGame) { - const sessionUserKey = currentSessionUserKey(); - const members = activeGameMembers(activeGame); - if (sessionUserKey && (!activeGame || members.some((member) => member.userKey === sessionUserKey))) { - return sessionUserKey; +function sourceIdeaFor(game) { + const sourceIdea = isRecord(game?.sourceIdea) ? game.sourceIdea : null; + const idea = String(sourceIdea?.idea || "").trim(); + const pitch = String(sourceIdea?.pitch || "").trim(); + const notes = Array.isArray(sourceIdea?.notes) + ? sourceIdea.notes.map((note) => String(note || "").trim()).filter(Boolean) + : []; + + return { idea, notes, pitch }; +} + +function projectDescription(game) { + const sourceIdea = sourceIdeaFor(game); + if (sourceIdea.pitch) { + return sourceIdea.pitch; } - return activeGame?.ownerKey || members.find((member) => member.permission === "Owner")?.userKey || ""; + return `${game?.purpose || "Game"} project`; } -function currentGameMember(activeGame) { - const userKey = currentGameUserKey(activeGame); - return activeGameMembers(activeGame).find((member) => member.userKey === userKey) || null; +function projectUpdated(game) { + const updated = String(game?.updated || game?.updatedAt || game?.lastUpdated || "").trim(); + if (updated) { + return updated; + } + return new Date().toISOString().slice(0, 10); +} + +function journeyItemsFor(game) { + return sourceIdeaFor(game).notes.map((note) => ({ + status: "Planned", + text: note, + })); } -function createGameButton(game, isActive) { +function journeyCountLabel(game) { + const count = journeyItemsFor(game).length; + return `${count} ${count === 1 ? "Item" : "Items"}`; +} + +function createCell(text, datasetName) { + const cell = document.createElement("td"); + cell.textContent = text; + if (datasetName) { + cell.dataset[datasetName] = "true"; + } + return cell; +} + +function createProjectCell(game, expanded, active) { + const cell = document.createElement("td"); + cell.dataset.gameToggle = game.id; + cell.dataset.tableParentCell = "true"; + cell.setAttribute("aria-expanded", expanded ? "true" : "false"); + cell.setAttribute("role", "button"); + cell.tabIndex = 0; + + const label = document.createElement("span"); + label.className = "table-parent-label"; + + const chevron = document.createElement("span"); + chevron.className = `table-parent-chevron table-parent-chevron--${expanded ? "up" : "down"}`; + chevron.setAttribute("aria-hidden", "true"); + + const text = document.createElement("span"); + text.className = "table-parent-label__text"; + text.textContent = game.name; + if (active) { + text.dataset.activeGameName = "true"; + } + + label.append(chevron, text); + cell.append(label); + return cell; +} + +function createOpenButton(game, active) { const button = document.createElement("button"); - button.className = isActive ? "btn primary" : "btn"; + button.className = active ? "btn primary" : "btn"; button.type = "button"; button.dataset.gameOpen = game.id; - if (isActive) { + button.textContent = "Open"; + button.setAttribute("aria-label", `Open ${game.name}`); + if (active) { button.dataset.gameActive = "true"; button.setAttribute("aria-current", "true"); } - button.textContent = isActive ? `Open ${game.name} (Active)` : `Open ${game.name}`; return button; } -function renderProjectInformation(activeGame, currentMember, progress) { - if (!elements.projectRecordsTable) { - return; - } +function createDeleteButton(game) { + const button = document.createElement("button"); + button.className = "btn"; + button.type = "button"; + button.dataset.gameDelete = game.id; + button.textContent = "Delete"; + button.disabled = !projectRecordsSaveAllowed(); + button.setAttribute("aria-label", `Delete ${game.name}`); + return button; +} - elements.projectRecordsTable.replaceChildren(); - const row = document.createElement("tr"); - [ - { datasetName: "activeGameName", value: activeGame?.name || "No game open" }, - { datasetName: "activeGameStatus", value: activeGame?.status || progress?.gameStatus || "No Game" }, - { datasetName: "activeGamePurpose", value: activeGame?.purpose || "No purpose" }, - { datasetName: "activeGameOwner", value: activeGame?.ownerDisplayName || "No owner" }, - { datasetName: "currentUserRole", value: currentMember?.role || "Viewer" }, - { datasetName: "recommendedNextTool", value: progress?.recommendedNextTool || "Game Hub" }, - ].forEach(({ datasetName, value }) => { - const cell = document.createElement("td"); - cell.dataset[datasetName] = "true"; - cell.textContent = value; - row.append(cell); - }); - elements.projectRecordsTable.append(row); +function createJourneyLink(game, compact = false) { + const link = document.createElement("a"); + link.className = compact ? "btn btn--compact" : "btn"; + link.href = `toolbox/game-journey/index.html?game=${encodeURIComponent(game.id)}`; + link.dataset.gameJourneyLink = "true"; + link.textContent = compact ? "Open" : "Open Journey"; + link.setAttribute("aria-label", `Open Game Journey for ${game.name}`); + return link; +} - setProjectRecordStatus(projectRecordsSaveAllowed() - ? "Project Information loaded." - : "Project Information loaded. Sign in to save changes."); +function createActionCell(game, active) { + const cell = document.createElement("td"); + const group = document.createElement("div"); + group.className = "action-group"; + group.append(createOpenButton(game, active), createJourneyLink(game), createDeleteButton(game)); + cell.append(group); + return cell; } -function renderGameList() { - if (!elements.gameList) { - return; - } +function appendSourceIdeaSection(surface, game) { + const sourceIdea = sourceIdeaFor(game); + const section = document.createElement("section"); + section.className = "content-stack content-stack--compact"; + section.dataset.sourceIdeaSection = "true"; + section.setAttribute("aria-label", "Source Idea"); - const activeGame = normalizeActiveGame(repository.getActiveGame()); - const gameUserKey = currentGameUserKey(activeGame); - const listResult = repository.listGames(gameUserKey ? { userKey: gameUserKey } : {}); - const games = Array.isArray(listResult) ? listResult : []; - if (!Array.isArray(listResult) && !reportRepositoryError(listResult, "Game list")) { - setStatusLog("Game list is temporarily unavailable. Refresh the page or try again shortly."); - } + const heading = document.createElement("h3"); + heading.dataset.sourceIdeaName = "true"; + heading.textContent = "Source Idea"; - elements.gameList.replaceChildren(); + const wrapper = document.createElement("div"); + wrapper.className = "table-wrapper"; - if (games.length === 0) { - const emptyState = document.createElement("p"); - emptyState.className = "status"; - emptyState.textContent = "No games. Create a game to continue."; - elements.gameList.append(emptyState); - return; - } + const table = document.createElement("table"); + table.className = "data-table"; + table.setAttribute("aria-label", `Source Idea for ${game.name}`); - games.forEach((game) => { - const row = document.createElement("article"); - row.className = "callout"; - row.dataset.gameRow = game.id; + const body = document.createElement("tbody"); + + const rows = [ + ["Idea", sourceIdea.idea || "No source idea yet", "sourceIdeaDisplay"], + ["Pitch", sourceIdea.pitch || "Create a project from Idea Board to see source details.", "sourceIdeaPitch"], + ]; + + rows.forEach(([label, value, datasetName]) => { + const row = document.createElement("tr"); + const header = document.createElement("th"); + header.scope = "row"; + header.textContent = label; + const cell = createCell(value, datasetName); + row.append(header, cell); + body.append(row); + }); + + const notesRow = document.createElement("tr"); + const notesHeader = document.createElement("th"); + notesHeader.scope = "row"; + notesHeader.textContent = "Notes"; + const notesCell = document.createElement("td"); + const notesList = document.createElement("ul"); + notesList.className = "content-list"; + notesList.dataset.sourceIdeaNotes = "true"; + const notes = sourceIdea.notes.length ? sourceIdea.notes : ["No source notes."]; + notes.forEach((note) => { + const item = document.createElement("li"); + item.textContent = note; + notesList.append(item); + }); + notesCell.append(notesList); + notesRow.append(notesHeader, notesCell); + body.append(notesRow); + + table.append(body); + wrapper.append(table); + section.append(heading, wrapper); + surface.append(section); +} - const title = document.createElement("h4"); - title.textContent = game.name; +function appendJourneyItemsSection(surface, game) { + const section = document.createElement("section"); + section.className = "content-stack content-stack--compact"; + section.setAttribute("aria-label", "Game Journey Items"); - const meta = document.createElement("p"); - meta.className = "eyebrow"; - meta.textContent = `${game.purpose} | ${game.status} | ${game.ownerDisplayName}`; + const heading = document.createElement("h3"); + heading.textContent = "Game Journey Items"; - const isActive = activeGame?.id === game.id; - const action = createGameButton(game, isActive); + const wrapper = document.createElement("div"); + wrapper.className = "table-wrapper"; - row.append(title, meta, action); + const table = document.createElement("table"); + table.className = "data-table"; + table.setAttribute("aria-label", `Game Journey Items for ${game.name}`); - elements.gameList.append(row); + const head = document.createElement("thead"); + const headRow = document.createElement("tr"); + ["Item", "Status", "Actions"].forEach((label) => { + const header = document.createElement("th"); + header.scope = "col"; + header.textContent = label; + headRow.append(header); }); + head.append(headRow); + + const body = document.createElement("tbody"); + const items = journeyItemsFor(game); + if (!items.length) { + const emptyRow = document.createElement("tr"); + const emptyCell = document.createElement("td"); + emptyCell.colSpan = 3; + emptyCell.textContent = "No journey items yet."; + emptyRow.append(emptyCell); + body.append(emptyRow); + } else { + items.forEach((item) => { + const row = document.createElement("tr"); + row.className = "table-child-row"; + + const itemCell = createCell(item.text); + const statusCell = document.createElement("td"); + const checkboxLabel = document.createElement("label"); + checkboxLabel.className = "checkbox-label"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.disabled = true; + checkbox.setAttribute("aria-label", `${item.text} planned`); + const statusText = document.createElement("span"); + statusText.textContent = item.status; + checkboxLabel.append(checkbox, statusText); + statusCell.append(checkboxLabel); + + const actionsCell = document.createElement("td"); + actionsCell.append(createJourneyLink(game, true)); + + row.append(itemCell, statusCell, actionsCell); + body.append(row); + }); + } + + table.append(head, body); + wrapper.append(table); + section.append(heading, wrapper); + surface.append(section); } -function renderMembersTable(activeGame) { - if (!elements.membersTable) { - return; - } +function createExpandedRow(game) { + const row = document.createElement("tr"); + row.dataset.gameProjectExpandedRow = game.id; + row.className = "table-child-row"; - elements.membersTable.replaceChildren(); + const cell = document.createElement("td"); + cell.colSpan = 6; - if (!activeGame) { - const row = document.createElement("tr"); - row.innerHTML = "No game---"; - elements.membersTable.append(row); - return; - } + const surface = document.createElement("div"); + surface.className = "table-child-surface content-stack"; + surface.dataset.gameProjectChildSurface = "true"; - activeGameMembers(activeGame).forEach((member) => { - const row = document.createElement("tr"); - const name = document.createElement("td"); - const userKey = document.createElement("td"); - const role = document.createElement("td"); - const permission = document.createElement("td"); - - name.textContent = member.displayName; - userKey.textContent = member.userKey; - role.textContent = member.role; - permission.textContent = member.permission; - - row.append(name, userKey, role, permission); - elements.membersTable.append(row); - }); + appendSourceIdeaSection(surface, game); + appendJourneyItemsSection(surface, game); + + cell.append(surface); + row.append(cell); + return row; } -function renderTableCounts() { - if (!elements.tableCounts) { - return; +function renderProjectRow(game, activeGame) { + const active = activeGame?.id === game.id; + const expanded = state.expandedGameId === game.id; + const row = document.createElement("tr"); + row.dataset.gameRow = game.id; + if (active) { + row.dataset.gameActiveRow = "true"; + } + + const descriptionCell = createCell(projectDescription(game), active ? "activeGamePurpose" : ""); + const statusCell = createCell(game.status || "Planning", active ? "activeGameStatus" : ""); + const updatedCell = createCell(projectUpdated(game)); + const journeyCell = createCell(journeyCountLabel(game)); + + row.append( + createProjectCell(game, expanded, active), + descriptionCell, + statusCell, + updatedCell, + journeyCell, + createActionCell(game, active), + ); + + return [row, expanded ? createExpandedRow(game) : null].filter(Boolean); +} + +function renderProjectsTable() { + if (!elements.projectsTable) { + return null; } - const tableResult = repository.getTables(); - const tables = isRecord(tableResult) && !isRepositoryErrorResult(tableResult) - ? tableResult - : { users: [], games: [], game_members: [] }; - if ((!isRecord(tableResult) || isRepositoryErrorResult(tableResult)) && !reportRepositoryError(tableResult, "Repository tables")) { - setStatusLog("Game Hub project details are temporarily unavailable. Refresh the page or try again shortly."); + const activeGame = normalizeGame(repository.getActiveGame(), "Active project"); + const userKey = currentSessionUserKey(); + const listResult = repository.listGames(userKey ? { userKey } : {}); + const games = normalizeGameList(listResult); + + if (state.expandedGameId && !games.some((game) => game.id === state.expandedGameId)) { + state.expandedGameId = ""; } - const rows = [ - ["users", Array.isArray(tables.users) ? tables.users.length : 0], - ["games", Array.isArray(tables.games) ? tables.games.length : 0], - ["game_members", Array.isArray(tables.game_members) ? tables.game_members.length : 0], - ]; - elements.tableCounts.replaceChildren(); + elements.projectsTable.replaceChildren(); - rows.forEach(([tableName, count]) => { + if (!games.length) { const row = document.createElement("tr"); - const tableCell = document.createElement("td"); - const countCell = document.createElement("td"); + const cell = document.createElement("td"); + cell.colSpan = 6; + cell.textContent = "No projects yet. Create a project to get started."; + row.append(cell); + elements.projectsTable.append(row); + } else { + games.flatMap((game) => renderProjectRow(game, activeGame)).forEach((row) => { + elements.projectsTable.append(row); + }); + } - tableCell.textContent = tableName; - countCell.textContent = String(count); + setProjectRecordStatus(projectRecordsSaveAllowed() + ? "Projects loaded." + : "Projects loaded. Sign in to create or update projects."); - row.append(tableCell, countCell); - elements.tableCounts.append(row); - }); + return activeGame; } -function renderSourceIdea(activeGame) { - const sourceIdea = isRecord(activeGame?.sourceIdea) ? activeGame.sourceIdea : null; - const name = String(sourceIdea?.idea || "").trim(); - const pitch = String(sourceIdea?.pitch || "").trim(); - const notes = Array.isArray(sourceIdea?.notes) - ? sourceIdea.notes.map((note) => String(note || "").trim()).filter(Boolean) - : []; +function refreshSaveControls() { + const saveAllowed = projectRecordsSaveAllowed(); + [elements.nameInput, elements.purposeInput, elements.statusInput, elements.currentUserRoleInput].forEach((control) => { + if (control) { + control.disabled = !saveAllowed; + } + }); - setText(elements.sourceIdeaName, name || "No source idea yet"); - setText(elements.sourceIdeaDisplay, name || "No source idea yet"); - setText(elements.sourceIdeaPitch, pitch || "Create a project from Idea Board to see source details."); - - if (elements.sourceIdeaNotes) { - elements.sourceIdeaNotes.replaceChildren(); - const visibleNotes = notes.length ? notes : ["No source notes."]; - visibleNotes.forEach((note) => { - const item = document.createElement("li"); - item.textContent = note; - elements.sourceIdeaNotes.append(item); - }); + const submitButton = elements.form?.querySelector("button[type='submit']"); + if (submitButton) { + submitButton.disabled = !saveAllowed; } -} -function renderChecklist(progress) { - if (!elements.progressChecklist) { - return; + if (!saveAllowed) { + const currentStatus = String(elements.statusLog?.textContent || ""); + if (!/failed|Sign in required|unavailable/i.test(currentStatus)) { + setStatusLog("Sign in to create or update Game Hub projects."); + } } +} - elements.progressChecklist.replaceChildren(); - - const checklist = Array.isArray(progress?.progressChecklist) ? progress.progressChecklist : []; - checklist.forEach((item) => { - const row = document.createElement("li"); - row.textContent = `${item.label}: ${item.status}`; - elements.progressChecklist.append(row); - }); +function ensureProjectRecordsSaveAllowed(action) { + if (projectRecordsSaveAllowed()) { + return true; + } + const message = `Sign in required to ${action} Game Hub projects.`; + setStatusLog(message); + setProjectRecordStatus(message); + refreshSaveControls(); + return false; } -function renderWorkspace() { - const activeGame = normalizeActiveGame(repository.getActiveGame()); - const progress = normalizeProgress(repository.getGameProgress()); +function renderRole(activeGame) { const currentMember = currentGameMember(activeGame); - - setText(elements.activeGameName, activeGame?.name || "No game open"); - setText(elements.activeGameOwner, activeGame?.ownerDisplayName || "No owner"); - setText(elements.activeGamePurpose, activeGame?.purpose || "No purpose"); - setText(elements.activeGameStatus, activeGame?.status || "No Game"); - setText(elements.currentUserRole, currentMember?.role || "Viewer"); - setText(elements.gameStatus, progress.gameStatus); - setText(elements.gameProgress, progress.gameProgress); - setText(elements.publishingProgress, progress.publishingProgress); - setText(elements.currentFocus, progress.currentFocus); - setText(elements.recommendedNextTool, progress.recommendedNextTool); - if (elements.purposeInput && activeGame?.purpose) { - elements.purposeInput.value = activeGame.purpose; - } - if (elements.gameStatusInput && activeGame?.status) { - elements.gameStatusInput.value = activeGame.status; - } + const role = currentMember?.role || "Viewer"; + setText(elements.currentUserRole, role); if (elements.currentUserRoleInput) { - elements.currentUserRoleInput.value = currentMember?.role || "Viewer"; - } - if (elements.gameJourneyLink) { - if (activeGame) { - elements.gameJourneyLink.href = `toolbox/game-journey/index.html?game=${encodeURIComponent(activeGame.id)}`; - elements.gameJourneyLink.setAttribute("aria-disabled", "false"); - } else { - elements.gameJourneyLink.href = "toolbox/game-journey/index.html?game=none"; - elements.gameJourneyLink.setAttribute("aria-disabled", "true"); - } + elements.currentUserRoleInput.value = role; } +} - renderGameList(); - renderMembersTable(activeGame); - renderTableCounts(); - renderChecklist(progress); - renderProjectInformation(activeGame, currentMember, progress); - renderSourceIdea(activeGame); +function renderWorkspace() { + const activeGame = renderProjectsTable(); + renderRole(activeGame); refreshSaveControls(); } +function toggleProject(projectId) { + state.expandedGameId = state.expandedGameId === projectId ? "" : projectId; + renderWorkspace(); +} + elements.form?.addEventListener("submit", (event) => { event.preventDefault(); if (!ensureProjectRecordsSaveAllowed("create")) { return; } - const activeGame = normalizeActiveGame(repository.getActiveGame()); + const game = repository.createGame({ name: elements.nameInput?.value, purpose: elements.purposeInput?.value, - status: elements.gameStatusInput?.value, + status: elements.statusInput?.value, }); - if (reportRepositoryError(game, "Create Game") || !isRecord(game) || !String(game.name || "").trim()) { + if (reportRepositoryError(game, "Create Project") || !isRecord(game) || !String(game.name || "").trim()) { if (!isRepositoryErrorResult(game)) { - setStatusLog("Create Game could not be completed. Refresh the page or try again shortly."); + setStatusLog("Create Project could not be completed. Refresh the page or try again shortly."); } renderWorkspace(); return; @@ -450,97 +528,94 @@ elements.form?.addEventListener("submit", (event) => { if (elements.nameInput) { elements.nameInput.value = ""; } - + state.expandedGameId = game.id; setStatusLog(`Created and opened ${game.name}.`); renderWorkspace(); }); -elements.gameList?.addEventListener("click", (event) => { - const button = event.target.closest("[data-game-open]"); - - if (!button) { +elements.projectsTable?.addEventListener("click", (event) => { + const toggleCell = event.target.closest("[data-game-toggle]"); + if (toggleCell && elements.projectsTable.contains(toggleCell)) { + toggleProject(toggleCell.dataset.gameToggle); return; } - const game = repository.openGame(button.dataset.gameOpen); - - if (game) { - setStatusLog(`Opened ${game.name}.`); - renderWorkspace(); - } -}); - -elements.deleteOpenGame?.addEventListener("click", () => { - if (!ensureProjectRecordsSaveAllowed("delete")) { + const openButton = event.target.closest("[data-game-open]"); + if (openButton && elements.projectsTable.contains(openButton)) { + const game = repository.openGame(openButton.dataset.gameOpen); + if (reportRepositoryError(game, "Open Project")) { + renderWorkspace(); + return; + } + if (game) { + setStatusLog(`Opened ${game.name}.`); + renderWorkspace(); + } return; } - const activeGame = normalizeActiveGame(repository.getActiveGame(), "Delete active game"); - if (!activeGame) { - setStatusLog("No game is open for deletion."); + const deleteButton = event.target.closest("[data-game-delete]"); + if (deleteButton && elements.projectsTable.contains(deleteButton)) { + if (!ensureProjectRecordsSaveAllowed("delete")) { + return; + } + const gameId = deleteButton.dataset.gameDelete; + const gameName = deleteButton.getAttribute("aria-label")?.replace(/^Delete\s+/, "") || "project"; + repository.deleteGame(gameId); + if (state.expandedGameId === gameId) { + state.expandedGameId = ""; + } + setStatusLog(`Deleted ${gameName}.`); renderWorkspace(); - return; } - - repository.deleteGame(activeGame.id); - setStatusLog(`Deleted ${activeGame.name}.`); - renderWorkspace(); }); -elements.purposeInput?.addEventListener("change", () => { - if (!ensureProjectRecordsSaveAllowed("update")) { - return; - } - const activeGame = normalizeActiveGame(repository.getActiveGame(), "Update game purpose"); - if (!activeGame) { +elements.projectsTable?.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") { return; } - const game = repository.updateGamePurpose(activeGame.id, elements.purposeInput.value); - setStatusLog(`Updated ${game.name} purpose to ${game.purpose}.`); - renderWorkspace(); -}); - -elements.gameStatusInput?.addEventListener("change", () => { - if (!ensureProjectRecordsSaveAllowed("update")) { - return; - } - const activeGame = normalizeActiveGame(repository.getActiveGame(), "Update game status"); - if (!activeGame) { + const toggleCell = event.target.closest("[data-game-toggle]"); + if (!toggleCell || !elements.projectsTable.contains(toggleCell)) { return; } - const game = repository.updateGameStatus(activeGame.id, elements.gameStatusInput.value); - setStatusLog(`Updated ${game.name} status to ${game.status}.`); - renderWorkspace(); + event.preventDefault(); + toggleProject(toggleCell.dataset.gameToggle); }); elements.currentUserRoleInput?.addEventListener("change", () => { if (!ensureProjectRecordsSaveAllowed("update")) { return; } - const activeGame = normalizeActiveGame(repository.getActiveGame(), "Update current user role"); + const activeGame = normalizeGame(repository.getActiveGame(), "Update current role"); if (!activeGame) { return; } repository.updateGameMemberRole(activeGame.id, currentGameUserKey(activeGame), elements.currentUserRoleInput.value); - setStatusLog(`Updated current user role to ${elements.currentUserRoleInput.value}.`); + setStatusLog(`Updated current role to ${elements.currentUserRoleInput.value}.`); renderWorkspace(); }); populateSelect(elements.purposeInput, GAME_WORKSPACE_GAME_PURPOSES); -populateSelect(elements.gameStatusInput, GAME_WORKSPACE_GAME_STATUSES); +populateSelect(elements.statusInput, GAME_WORKSPACE_GAME_STATUSES); populateSelect(elements.currentUserRoleInput, GAME_WORKSPACE_MEMBER_ROLES); +if (elements.statusInput && GAME_WORKSPACE_GAME_STATUSES.includes("Under Construction")) { + elements.statusInput.value = "Under Construction"; +} + const requestedGameId = new URL(window.location.href).searchParams.get("game"); if (requestedGameId) { const openedGame = repository.openGame(requestedGameId); if (isRepositoryErrorResult(openedGame)) { - setStatusLog(repositoryErrorMessage(openedGame, "Open Game")); + setStatusLog(repositoryErrorMessage("Open Project")); } else if (openedGame) { + state.expandedGameId = openedGame.id; setStatusLog(`Opened ${openedGame.name}.`); } else { setStatusLog("That Game Hub project could not be found."); } } + renderWorkspace(); diff --git a/toolbox/game-workspace/index.html b/toolbox/game-workspace/index.html index f204166d8..afed04469 100644 --- a/toolbox/game-workspace/index.html +++ b/toolbox/game-workspace/index.html @@ -6,7 +6,7 @@ Game Hub - GameFoundryStudio - + @@ -18,7 +18,7 @@
Toolbox / Game Hub

Game Hub

-

Create, open, and delete games while reviewing progress and publish readiness.

+

Create, open, and review projects from one table.

@@ -30,132 +30,57 @@

Game Hub

- Game Setup + Project Setup
- +
- - + + - + - +
- +
- - -
-
-
- Open Games -
-
-

Project Information

-

Review the open project and its source idea.

-
Project Information ready.
-
-
-
- - - - - - - - - - - - - - - - - - - - - - -
Project Information
ProjectStatusPurposeOwnerRoleNext Tool
Demo GameUnder ConstructionGameNo ownerOwnerGame Configuration
-
- -
-
-
-
-
-
Source Idea
-

No source idea yet

-
-
- - - - - - -
IdeaNo source idea yet
PitchCreate a project from Idea Board to see source details.
Notes
  • No source notes.
-
-
-
-
-
-
-
Game Foundation
-

Game Progress

-
-
-

Game Status

Under Construction

-

Game Progress

Demo Game identity ready

-

Launch Progress

Publish blocked until configuration and required assets are ready

-
-
-

Current Focus

Complete Game Configuration

-

Recommended Next Tool

Game Configuration

-

Checklist

  • Game identity: Complete
-
-
Game Hub ready.
-
-
-
-
- Readiness Output -
-
- - - - - - - - - - -
Readiness output
PathStatusNext Tool
PlanUnder ConstructionGame Configuration
ConfigurePlannedBuild Game
ReleasePlannedPublish
-
-
-
+
+

Projects

+

Expand a project to review its Source Idea and Game Journey Items.

+
Projects ready.
+
+ + + + + + + + + + + + + + + + + +
Projects
ProjectDescriptionStatusUpdatedJourneyActions
Loading projects.
+
+ Project Role +
+

Current role: Viewer

+ +
+
Status Log
-

Game Hub actions report status in the center panel.

-
Game data ready.
+
Game Hub ready.