diff --git a/assets/theme-v2/css/status.css b/assets/theme-v2/css/status.css index 5c521c45d..88e0d5e64 100644 --- a/assets/theme-v2/css/status.css +++ b/assets/theme-v2/css/status.css @@ -35,6 +35,119 @@ line-height: var(--line-height-copy) } +.toolbox-status-bar { + width: 100%; + border-block: var(--border-standard); + background: var(--panel-overlay-strong); + color: var(--text) +} + +.toolbox-status-bar__inner { + width: var(--container-width); + margin: var(--space-0) auto; + padding: var(--space-10) var(--space-0); + display: grid; + grid-template-columns: minmax(var(--space-0), 1fr) minmax(var(--space-0), 2fr); + gap: var(--space-16); + align-items: center +} + +.toolbox-status-bar__game { + min-width: var(--space-0); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-14); + text-align: left +} + +.toolbox-status-bar__field { + min-width: var(--space-0); + display: grid; + gap: var(--space-3) +} + +.toolbox-status-bar__label { + color: var(--muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-heavy); + letter-spacing: var(--letter-spacing-kicker); + text-transform: uppercase +} + +.toolbox-status-bar__game-name { + color: var(--text); + font-size: var(--font-size-base); + overflow-wrap: anywhere +} + +.toolbox-status-bar__purpose { + color: var(--muted); + font-size: var(--font-size-sm); + overflow-wrap: anywhere +} + +.toolbox-status-bar__center { + min-width: var(--space-0); + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--space-10); + text-align: center +} + +.toolbox-status-bar__context-type { + flex: 0 0 auto +} + +.toolbox-status-bar__message { + margin: var(--space-0); + max-width: var(--measure-lg); + overflow-wrap: anywhere +} + +.toolbox-status-bar__action { + flex: 0 0 auto +} + +.toolbox-status-bar[data-selected-game-state="active"] { + border-color: color-mix(in srgb, var(--green) 52%, var(--line)) +} + +.toolbox-status-bar[data-selected-game-state="missing"] { + border-color: var(--gold-border-muted) +} + +.toolbox-status-bar[data-selected-game-state="error"] { + border-color: color-mix(in srgb, var(--red) 52%, var(--line)) +} + +.toolbox-status-bar[data-toolbox-status-context-kind="error"] .toolbox-status-bar__context-type { + border-color: color-mix(in srgb, var(--red) 62%, var(--line)); + color: var(--red) +} + +.toolbox-status-bar[data-toolbox-status-context-kind="warning"] .toolbox-status-bar__context-type, +.toolbox-status-bar[data-toolbox-status-context-kind="validation"] .toolbox-status-bar__context-type { + border-color: var(--gold-border-muted); + color: var(--gold) +} + +.toolbox-status-bar[data-toolbox-status-context-kind="save"] .toolbox-status-bar__context-type { + border-color: color-mix(in srgb, var(--green) 62%, var(--line)); + color: var(--green) +} + +body.tool-focus-mode .toolbox-status-bar { + position: fixed; + inset-block-end: var(--space-0); + inset-inline: var(--space-0); + z-index: var(--z-index-lg); + border-block-end: var(--space-0); + box-shadow: var(--shadow-md) +} + .platform-banner { width: 100%; border-bottom: var(--border-standard); @@ -144,3 +257,16 @@ color: var(--bg); text-shadow: none } + +@media (max-width: 720px) { + .toolbox-status-bar__inner { + width: var(--container-width); + grid-template-columns: 1fr; + text-align: center + } + + .toolbox-status-bar__game { + justify-content: center; + text-align: center + } +} diff --git a/assets/theme-v2/js/gamefoundry-partials.js b/assets/theme-v2/js/gamefoundry-partials.js index 44b301916..f7f220880 100644 --- a/assets/theme-v2/js/gamefoundry-partials.js +++ b/assets/theme-v2/js/gamefoundry-partials.js @@ -1048,6 +1048,32 @@ } } + async function renderToolboxStatusBar() { + const pagePath = currentPagePath() || ""; + if (pagePath.indexOf("toolbox/") !== 0) { + return; + } + try { + const module = await import(assetUrl("js/toolbox-status-bar.js")); + if (typeof module.mountToolboxStatusBar === "function") { + module.mountToolboxStatusBar({ + gameHubHref: routeHref("game-hub"), + pagePath + }); + } + } catch (error) { + console.warn("[toolbox/status] Shared status bar could not be loaded.", error); + } + } + + function refreshSharedSurfaces() { + renderPlatformBanner() + .then(renderToolboxStatusBar) + .catch(function (error) { + console.error(error); + }); + } + enforcePageProtection(); document.addEventListener("DOMContentLoaded", function () { enforcePageProtection(); @@ -1056,11 +1082,11 @@ replaceExisting("header-nav", "header.site-header"), replaceExisting("footer", "footer.footer") ]; - Promise.all(tasks).then(renderPlatformBanner).catch(function (error) { + Promise.all(tasks).then(refreshSharedSurfaces).catch(function (error) { console.error(error); }); }); window.addEventListener("gamefoundry:session-user-changed", refreshHeaderLoginState); window.addEventListener("gamefoundry:data-changed", refreshHeaderOnly); - window.addEventListener("gamefoundry:platform-settings-changed", renderPlatformBanner); + window.addEventListener("gamefoundry:platform-settings-changed", refreshSharedSurfaces); }()); diff --git a/assets/theme-v2/js/toolbox-status-bar.js b/assets/theme-v2/js/toolbox-status-bar.js new file mode 100644 index 000000000..fdb367328 --- /dev/null +++ b/assets/theme-v2/js/toolbox-status-bar.js @@ -0,0 +1,348 @@ +import { createServerRepositoryClient } from "/src/api/server-api-client.js"; + +const EXCLUDED_SELECTED_GAME_TOOLS = new Set(["idea-board"]); +const STATUS_BAR_SELECTOR = "[data-toolbox-status-bar]"; + +let repository = null; +let messageObserver = null; +let listenersInstalled = false; +let latestToolMessage = ""; +let pendingToolMessageRefresh = 0; +let mountOptions = { + gameHubHref: "toolbox/game-hub/index.html", + pagePath: "", +}; + +function getRepository() { + if (!repository) { + repository = createServerRepositoryClient("game-hub"); + } + return repository; +} + +function toolSlugFromPath(pagePath) { + const parts = String(pagePath || window.location.pathname || "") + .replace(/^\/+/, "") + .split("/") + .filter(Boolean); + if (parts[0] !== "toolbox") { + return ""; + } + const slug = (parts[1] || "index").replace(/\.html$/i, ""); + return slug || "index"; +} + +function pageRequiresSelectedGame() { + const slug = toolSlugFromPath(mountOptions.pagePath); + return !EXCLUDED_SELECTED_GAME_TOOLS.has(slug); +} + +function isRepositoryError(value) { + return Boolean(value && typeof value === "object" && value.error === true); +} + +function normalizeSelectedGame(value) { + if (!value) { + return null; + } + if (isRepositoryError(value)) { + throw new Error(value.message || "Game Hub selected game is unavailable."); + } + const id = String(value.id || "").trim(); + const name = String(value.name || "").trim(); + if (!id || !name || !Array.isArray(value.members)) { + throw new Error("Game Hub selected game payload is malformed."); + } + return Object.freeze({ + id, + name, + ownerKey: String(value.ownerKey || "").trim(), + purpose: String(value.purpose || "Game").trim() || "Game", + status: String(value.status || "").trim(), + }); +} + +function readSelectedGame() { + return normalizeSelectedGame(getRepository().getActiveGame()); +} + +function createText(tagName, className, datasetName) { + const element = document.createElement(tagName); + if (className) { + element.className = className; + } + if (datasetName) { + element.dataset[datasetName] = ""; + } + return element; +} + +function createStatusBar() { + const bar = document.createElement("section"); + bar.className = "toolbox-status-bar"; + bar.dataset.toolboxStatusBar = ""; + bar.setAttribute("aria-label", "Toolbox selected game status"); + + const inner = document.createElement("div"); + inner.className = "toolbox-status-bar__inner"; + + const game = document.createElement("div"); + game.className = "toolbox-status-bar__game"; + game.dataset.toolboxSelectedGame = ""; + + const nameField = document.createElement("div"); + nameField.className = "toolbox-status-bar__field"; + nameField.dataset.toolboxSelectedGameNameField = ""; + const nameLabel = createText("span", "toolbox-status-bar__label", "toolboxSelectedGameNameLabel"); + nameLabel.textContent = "Selected Game Name"; + const name = createText("strong", "toolbox-status-bar__game-name", "toolboxSelectedGameName"); + nameField.append(nameLabel, name); + + const purposeField = document.createElement("div"); + purposeField.className = "toolbox-status-bar__field"; + purposeField.dataset.toolboxSelectedGamePurposeField = ""; + const purposeLabel = createText("span", "toolbox-status-bar__label", "toolboxSelectedGamePurposeLabel"); + purposeLabel.textContent = "Selected Game Purpose"; + const purpose = createText("span", "toolbox-status-bar__purpose", "toolboxSelectedGamePurpose"); + purpose.dataset.toolboxSelectedGameMeta = ""; + purposeField.append(purposeLabel, purpose); + game.append(nameField, purposeField); + + const center = document.createElement("div"); + center.className = "toolbox-status-bar__center"; + center.dataset.toolboxStatusCenter = ""; + + const contextType = createText("span", "pill toolbox-status-bar__context-type", "toolboxStatusContextType"); + const message = createText("p", "toolbox-status-bar__message status", "toolboxStatusMessage"); + message.setAttribute("role", "status"); + const action = document.createElement("a"); + action.className = "btn btn--compact toolbox-status-bar__action"; + action.dataset.toolboxStatusAction = ""; + action.href = mountOptions.gameHubHref; + action.textContent = "Open Game Hub"; + center.append(contextType, message, action); + + inner.append(game, center); + bar.append(inner); + return bar; +} + +function ensureStatusBar() { + let bar = document.querySelector(STATUS_BAR_SELECTOR); + if (!bar) { + bar = createStatusBar(); + } + placeStatusBar(bar); + return bar; +} + +function placeStatusBar(bar) { + const footer = document.querySelector("footer.footer"); + if (footer?.parentNode) { + if (bar.nextElementSibling !== footer) { + footer.before(bar); + } + return; + } + + const main = document.querySelector("main"); + if (main?.parentNode) { + main.after(bar); + return; + } + + document.body.append(bar); +} + +function visibleStatusText(element) { + if (!element || element.closest(STATUS_BAR_SELECTOR) || element.hidden) { + return ""; + } + if (element.closest("[hidden]")) { + return ""; + } + return String(element.textContent || "").replace(/\s+/g, " ").trim(); +} + +function readToolMessage() { + const messages = Array.from(document.querySelectorAll("main [role='status'], main .status")) + .map((element, index) => ({ + index, + priority: Object.keys(element.dataset || {}).length > 0 ? 1 : 0, + text: visibleStatusText(element), + })) + .filter((entry) => entry.text); + const prioritized = messages + .filter((entry) => entry.priority > 0) + .pop(); + return prioritized?.text || messages[messages.length - 1]?.text || ""; +} + +function updateLatestToolMessage() { + const nextMessage = readToolMessage(); + if (nextMessage && nextMessage !== latestToolMessage) { + latestToolMessage = nextMessage; + refreshToolboxStatusBar(); + } +} + +function scheduleToolMessageRefresh() { + window.clearTimeout(pendingToolMessageRefresh); + pendingToolMessageRefresh = window.setTimeout(updateLatestToolMessage, 0); + window.setTimeout(updateLatestToolMessage, 120); +} + +function observeToolMessages() { + messageObserver?.disconnect(); + const main = document.querySelector("main"); + if (!main) { + return; + } + latestToolMessage = readToolMessage(); + messageObserver = new MutationObserver(updateLatestToolMessage); + messageObserver.observe(main, { + characterData: true, + childList: true, + subtree: true, + }); +} + +function publishSelectedGameContext(selectedGame, state) { + const required = pageRequiresSelectedGame(); + const context = Object.freeze({ + required, + selectedGame, + source: "game-hub", + state, + }); + window.GameFoundryToolboxSelectedGame = context; + document.body.dataset.toolboxSelectedGameFilter = required ? state : "optional"; + document.body.dataset.toolboxSelectedGameSource = "game-hub"; + if (selectedGame) { + document.body.dataset.toolboxSelectedGameId = selectedGame.id; + } else { + delete document.body.dataset.toolboxSelectedGameId; + } + document.body.classList.toggle("toolbox-selected-game-missing", required && state === "missing"); + document.body.classList.toggle("toolbox-selected-game-unavailable", required && state === "error"); + document.body.classList.toggle("toolbox-selected-game-optional", !required); + window.dispatchEvent(new CustomEvent("gamefoundry:toolbox-selected-game-context", { + detail: context, + })); +} + +function classifyToolContext(messageText, state, required) { + const text = String(messageText || "").trim(); + if (state === "error") { + return { kind: "error", label: "Error" }; + } + if (required && state === "missing") { + return { kind: "action", label: "Tool Action" }; + } + if (/\b(error|failed|malformed|unavailable|could not)\b/i.test(text)) { + return { kind: "error", label: "Error" }; + } + if (/\b(sign in|refresh|try again|temporarily|blocked)\b/i.test(text)) { + return { kind: "warning", label: "Warning" }; + } + if (/\b(validation|requirement|requirements|missing|required|open or seed)\b/i.test(text)) { + return { kind: "validation", label: "Validation" }; + } + if (/\b(saved|created|deleted|updated|loaded|save changes)\b/i.test(text)) { + return { kind: "save", label: "Save State" }; + } + return { kind: "action", label: "Tool Action" }; +} + +function renderSelectedGame(bar, selectedGame, state, messageText) { + const required = pageRequiresSelectedGame(); + const name = bar.querySelector("[data-toolbox-selected-game-name]"); + const purpose = bar.querySelector("[data-toolbox-selected-game-purpose]"); + const contextType = bar.querySelector("[data-toolbox-status-context-type]"); + const message = bar.querySelector("[data-toolbox-status-message]"); + const action = bar.querySelector("[data-toolbox-status-action]"); + const nextMessage = messageText || latestToolMessage || (selectedGame + ? `Tool context is filtered to ${selectedGame.name}.` + : required + ? "Select or create a game in Game Hub before using this toolbox page." + : "Idea Board can capture ideas before a Game Hub game exists."); + const context = classifyToolContext(nextMessage, state, required); + + bar.dataset.selectedGameState = state; + bar.dataset.selectedGameRequired = String(required); + bar.dataset.toolboxStatusContextKind = context.kind; + contextType.textContent = context.label; + action.hidden = false; + action.href = mountOptions.gameHubHref; + + if (selectedGame) { + name.textContent = selectedGame.name; + purpose.textContent = selectedGame.purpose || "Game"; + message.textContent = nextMessage; + action.textContent = "Open Game Hub"; + return; + } + + if (!required) { + name.textContent = "No game selected"; + purpose.textContent = "Idea Board optional"; + message.textContent = nextMessage; + action.textContent = "Open Game Hub"; + return; + } + + if (state === "error") { + name.textContent = "Unavailable"; + purpose.textContent = "Game Hub selected game could not be read"; + message.textContent = nextMessage; + action.textContent = "Open Game Hub"; + return; + } + + name.textContent = "No game selected"; + purpose.textContent = "Game Hub owns game selection"; + message.textContent = "Select or create a game in Game Hub before using this toolbox page."; + action.textContent = "Select or Create in Game Hub"; +} + +export function refreshToolboxStatusBar() { + const bar = ensureStatusBar(); + let selectedGame = null; + let state = "missing"; + let message = ""; + + try { + selectedGame = readSelectedGame(); + state = selectedGame ? "active" : "missing"; + } catch (error) { + state = "error"; + message = error instanceof Error ? error.message : String(error || "Game Hub selected game is unavailable."); + } + + publishSelectedGameContext(selectedGame, state); + renderSelectedGame(bar, selectedGame, state, message); + placeStatusBar(bar); +} + +function installEventListeners() { + if (listenersInstalled) { + return; + } + listenersInstalled = true; + document.addEventListener("click", scheduleToolMessageRefresh, true); + document.addEventListener("submit", scheduleToolMessageRefresh, true); + document.addEventListener("change", scheduleToolMessageRefresh, true); + window.addEventListener("gamefoundry:toolbox-selected-game-changed", refreshToolboxStatusBar); + window.addEventListener("gamefoundry:data-changed", refreshToolboxStatusBar); +} + +export function mountToolboxStatusBar(options = {}) { + mountOptions = { + ...mountOptions, + ...options, + }; + ensureStatusBar(); + observeToolMessages(); + installEventListeners(); + refreshToolboxStatusBar(); +} diff --git a/docs_build/dev/BUILD_PR.md b/docs_build/dev/BUILD_PR.md index 20d0cb9f0..65c4d9709 100644 --- a/docs_build/dev/BUILD_PR.md +++ b/docs_build/dev/BUILD_PR.md @@ -1,27 +1,59 @@ -# BUILD_PR: Schema location correction +# PR_26175_ALFA_002-toolbox-status-bar-context-polish -## Codex task -Move the schema contract plan from root-level schema files to `src/shared/schemas/`. +## Purpose +Polish the shared toolbox status bar context display so it shows only selected-game name/purpose on the left and categorized tool context in the center. -## Required changes -1. Remove any planned or newly-added root-level `*.schema.json` files. -2. Add schema contracts under `src/shared/schemas/` only. -3. Put reusable manifest schemas directly under `src/shared/schemas/`. -4. Put individual tool payload schemas under `src/shared/schemas/tools/`. -5. Update any docs or references to point to the new schema paths. +## Source Of Truth +This `BUILD_PR.md` is the source of truth for `PR_26175_ALFA_002-toolbox-status-bar-context-polish`. -## Do not -- Do not write broad validation utilities as the primary deliverable. -- Do not change sample payloads. -- Do not unlock or mutate samples. -- Do not create schema files at repository root. -- Do not modify start_of_day folders. +## Exact Scope +- Do not include environment text in the status bar because environment already appears in the platform banner. +- On the left side, display the selected Game Hub game name and selected Game Hub game purpose. +- On the center side, display tool context messages for tool actions, save state, validation messages, warnings, or errors. +- Preserve normal placement above the footer. +- Preserve fullscreen/tool display mode bottom anchoring. +- Preserve Idea Board selected-game filtering exclusion. +- Preserve Game Hub as selected-game owner through the existing repository contract. +- Keep the shared Theme V2 toolbox component model. +- Update targeted Playwright coverage for the polished left and center context. -## Validation command -Search for misplaced schemas: +## Exact Targets +- `docs_build/dev/BUILD_PR.md` +- `assets/theme-v2/js/toolbox-status-bar.js` +- `assets/theme-v2/css/status.css` +- `tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs` +- `docs_build/dev/reports/PR_26175_ALFA_002-toolbox-status-bar-context-polish_report.md` +- `docs_build/dev/reports/PR_26175_ALFA_002-toolbox-status-bar-context-polish_validation-lane.md` +- `docs_build/dev/reports/PR_26175_ALFA_002-toolbox-status-bar-context-polish_requirements-checklist.md` +- `docs_build/dev/reports/codex_review.diff` +- `docs_build/dev/reports/codex_changed_files.txt` + +## Out Of Scope +- No environment status in the toolbox status bar. +- No row highlights. +- No large banners. +- No modal-style status messages. +- No inline styles, style blocks, or page-local CSS. +- No API/service contract changes. +- No engine core changes. +- No `start_of_day` folder changes. + +## Validation +Run: + +```powershell +npx playwright test tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs --workers=1 +``` + +Also verify the changed source does not introduce inline styles or style blocks: ```powershell -Get-ChildItem -Path . -Filter *.schema.json -Recurse | Select-Object FullName +rg -n " String(value || "").trim()) +- .filter(Boolean) +- .join(" - "); ++function classifyToolContext(messageText, state, required) { ++ const text = String(messageText || "").trim(); ++ if (state === "error") { ++ return { kind: "error", label: "Error" }; ++ } ++ if (required && state === "missing") { ++ return { kind: "action", label: "Tool Action" }; ++ } ++ if (/\b(error|failed|malformed|unavailable|could not)\b/i.test(text)) { ++ return { kind: "error", label: "Error" }; ++ } ++ if (/\b(sign in|refresh|try again|temporarily|blocked)\b/i.test(text)) { ++ return { kind: "warning", label: "Warning" }; ++ } ++ if (/\b(validation|requirement|requirements|missing|required|open or seed)\b/i.test(text)) { ++ return { kind: "validation", label: "Validation" }; ++ } ++ if (/\b(saved|created|deleted|updated|loaded|save changes)\b/i.test(text)) { ++ return { kind: "save", label: "Save State" }; ++ } ++ return { kind: "action", label: "Tool Action" }; + } + + function renderSelectedGame(bar, selectedGame, state, messageText) { + const required = pageRequiresSelectedGame(); + const name = bar.querySelector("[data-toolbox-selected-game-name]"); +- const meta = bar.querySelector("[data-toolbox-selected-game-meta]"); ++ const purpose = bar.querySelector("[data-toolbox-selected-game-purpose]"); ++ const contextType = bar.querySelector("[data-toolbox-status-context-type]"); + const message = bar.querySelector("[data-toolbox-status-message]"); + const action = bar.querySelector("[data-toolbox-status-action]"); ++ const nextMessage = messageText || latestToolMessage || (selectedGame ++ ? `Tool context is filtered to ${selectedGame.name}.` ++ : required ++ ? "Select or create a game in Game Hub before using this toolbox page." ++ : "Idea Board can capture ideas before a Game Hub game exists."); ++ const context = classifyToolContext(nextMessage, state, required); + + bar.dataset.selectedGameState = state; + bar.dataset.selectedGameRequired = String(required); ++ bar.dataset.toolboxStatusContextKind = context.kind; ++ contextType.textContent = context.label; + action.hidden = false; + action.href = mountOptions.gameHubHref; + + if (selectedGame) { + name.textContent = selectedGame.name; +- meta.textContent = selectedGameMeta(selectedGame) || "Game Hub selected game"; +- message.textContent = messageText || latestToolMessage || `Data filtered to ${selectedGame.name}.`; ++ purpose.textContent = selectedGame.purpose || "Game"; ++ message.textContent = nextMessage; + action.textContent = "Open Game Hub"; + return; + } + + if (!required) { +- name.textContent = "Optional"; +- meta.textContent = "Idea Board can start before Game Hub creation"; +- message.textContent = latestToolMessage || "Idea Board can capture ideas before a Game Hub game exists."; ++ name.textContent = "No game selected"; ++ purpose.textContent = "Idea Board optional"; ++ message.textContent = nextMessage; + action.textContent = "Open Game Hub"; + return; + } + + if (state === "error") { + name.textContent = "Unavailable"; +- meta.textContent = "Game Hub selected game could not be read"; +- message.textContent = messageText || "Game Hub selected game is unavailable. Refresh or restore the Local API."; ++ purpose.textContent = "Game Hub selected game could not be read"; ++ message.textContent = nextMessage; + action.textContent = "Open Game Hub"; + return; + } + + name.textContent = "No game selected"; +- meta.textContent = "Game Hub owns game selection"; ++ purpose.textContent = "Game Hub owns game selection"; + message.textContent = "Select or create a game in Game Hub before using this toolbox page."; + action.textContent = "Select or Create in Game Hub"; + } +@@ -284,6 +329,9 @@ function installEventListeners() { + return; + } + listenersInstalled = true; ++ document.addEventListener("click", scheduleToolMessageRefresh, true); ++ document.addEventListener("submit", scheduleToolMessageRefresh, true); ++ document.addEventListener("change", scheduleToolMessageRefresh, true); + window.addEventListener("gamefoundry:toolbox-selected-game-changed", refreshToolboxStatusBar); + window.addEventListener("gamefoundry:data-changed", refreshToolboxStatusBar); + } +diff --git a/docs_build/dev/BUILD_PR.md b/docs_build/dev/BUILD_PR.md +index 51d6abdeb..65c4d9709 100644 +--- a/docs_build/dev/BUILD_PR.md ++++ b/docs_build/dev/BUILD_PR.md +@@ -1,41 +1,42 @@ +-# PR_26175_ALFA_001-toolbox-selected-game-status-bar ++# PR_26175_ALFA_002-toolbox-status-bar-context-polish + + ## Purpose +-Add one shared Theme V2 toolbox status bar that surfaces the Game Hub selected game and current tool messages across toolbox pages. ++Polish the shared toolbox status bar context display so it shows only selected-game name/purpose on the left and categorized tool context in the center. + + ## Source Of Truth +-This `BUILD_PR.md` is the source of truth for `PR_26175_ALFA_001-toolbox-selected-game-status-bar`. ++This `BUILD_PR.md` is the source of truth for `PR_26175_ALFA_002-toolbox-status-bar-context-polish`. + + ## Exact Scope +-- Add a shared toolbox status bar renderer loaded through Theme V2 shared partial bootstrapping. +-- Render the status bar above the footer in normal page mode. +-- Anchor the status bar to the viewport bottom while `body.tool-focus-mode` is active. +-- Read the selected game only from the Game Hub repository through the existing Local API/service contract. +-- Display the selected Game Hub game on the left side of the status bar. +-- Display tool actions, warnings, errors, save state, validation messages, or the missing-game prompt in the center of the status bar. +-- Expose the selected Game Hub game as derived page context for toolbox pages without persisting browser-owned product data. +-- Require selected-game context on toolbox pages except Idea Board, which remains excluded because ideas can exist before game creation. +-- Show a creator-safe prompt to select or create a game in Game Hub when no selected game exists. +-- Notify the shared status bar when Game Hub changes the selected game. +-- Add targeted Playwright coverage for placement, fullscreen anchoring, selected-game display/update, missing-game prompt, and Idea Board exclusion. ++- Do not include environment text in the status bar because environment already appears in the platform banner. ++- On the left side, display the selected Game Hub game name and selected Game Hub game purpose. ++- On the center side, display tool context messages for tool actions, save state, validation messages, warnings, or errors. ++- Preserve normal placement above the footer. ++- Preserve fullscreen/tool display mode bottom anchoring. ++- Preserve Idea Board selected-game filtering exclusion. ++- Preserve Game Hub as selected-game owner through the existing repository contract. ++- Keep the shared Theme V2 toolbox component model. ++- Update targeted Playwright coverage for the polished left and center context. + + ## Exact Targets +-- `assets/theme-v2/js/gamefoundry-partials.js` ++- `docs_build/dev/BUILD_PR.md` + - `assets/theme-v2/js/toolbox-status-bar.js` + - `assets/theme-v2/css/status.css` +-- `toolbox/game-hub/game-hub.js` + - `tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs` +-- `docs_build/dev/reports/PR_26175_ALFA_001-toolbox-selected-game-status-bar_report.md` +-- `docs_build/dev/reports/PR_26175_ALFA_001-toolbox-selected-game-status-bar_validation-lane.md` +-- `docs_build/dev/reports/PR_26175_ALFA_001-toolbox-selected-game-status-bar_requirements-checklist.md` ++- `docs_build/dev/reports/PR_26175_ALFA_002-toolbox-status-bar-context-polish_report.md` ++- `docs_build/dev/reports/PR_26175_ALFA_002-toolbox-status-bar-context-polish_validation-lane.md` ++- `docs_build/dev/reports/PR_26175_ALFA_002-toolbox-status-bar-context-polish_requirements-checklist.md` ++- `docs_build/dev/reports/codex_review.diff` ++- `docs_build/dev/reports/codex_changed_files.txt` + + ## Out Of Scope ++- No environment status in the toolbox status bar. ++- No row highlights. ++- No large banners. ++- No modal-style status messages. ++- No inline styles, style blocks, or page-local CSS. ++- No API/service contract changes. + - No engine core changes. + - No `start_of_day` folder changes. +-- No API/service contract changes. +-- No page-local CSS, inline styles, or style blocks. +-- No browser storage or browser-owned product data as selected-game source of truth. +-- No Idea Board selected-game filtering. + + ## Validation + Run: +@@ -47,12 +48,12 @@ npx playwright test tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs + Also verify the changed source does not introduce inline styles or style blocks: + + ```powershell +-rg -n " { ++ await route.fulfill({ ++ contentType: "application/json", ++ body: JSON.stringify({ ++ data: { ++ authenticated: true, ++ displayName: options.session.displayName || "User 1", ++ roleSlugs: options.session.roleSlugs || ["creator"], ++ userKey, ++ }, ++ ok: true, ++ }), ++ }); ++ }); ++ await page.request.post(`${server.baseUrl}/api/session/user`, { ++ data: { userKey }, ++ }); ++ } + + await workspaceV2CoverageReporter.start(page); + await page.goto(`${server.baseUrl}${pathName}`, { waitUntil: "networkidle" }); +@@ -84,6 +105,14 @@ function expectNoPageFailures(failures) { + expect(failures.consoleErrors).toEqual([]); + } + ++function creatorSession() { ++ return { ++ displayName: "User 1", ++ roleSlugs: ["creator"], ++ userKey: MOCK_DB_KEYS.users.user1, ++ }; ++} ++ + async function statusBarSnapshot(page) { + return page.locator("[data-toolbox-status-bar]").evaluate((bar) => { + const footer = document.querySelector("footer.footer"); +@@ -111,8 +140,13 @@ test("shared toolbox status bar shows selected Game Hub game above the footer", + const statusBar = page.locator("[data-toolbox-status-bar]"); + await expect(statusBar).toBeVisible(); + await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); ++ await expect(statusBar).not.toContainText("Environment"); ++ await expect(statusBar.locator("[data-toolbox-selected-game-name-label]")).toHaveText("Selected Game Name"); + await expect(statusBar.locator("[data-toolbox-selected-game-name]")).toHaveText("Demo Game"); +- await expect(statusBar.locator("[data-toolbox-selected-game-meta]")).toHaveText("Game - Under Construction"); ++ await expect(statusBar.locator("[data-toolbox-selected-game-purpose-label]")).toHaveText("Selected Game Purpose"); ++ await expect(statusBar.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Game"); ++ await expect(statusBar.locator("[data-toolbox-selected-game]")).not.toContainText("Under Construction"); ++ await expect(statusBar.locator("[data-toolbox-status-context-type]")).toHaveText("Tool Action"); + await expect(statusBar.locator("[data-toolbox-status-message]")).toContainText("Game Design mock repository ready."); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-id", "demo-game"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-filter", "active"); +@@ -130,6 +164,29 @@ test("shared toolbox status bar shows selected Game Hub game above the footer", + } + }); + ++test("shared toolbox status bar center reports save state after Game Hub saves", async ({ page }) => { ++ const failures = await openRepoPage(page, "/toolbox/game-hub/index.html", { session: creatorSession() }); ++ ++ try { ++ await page.getByRole("button", { name: "Add Game" }).click(); ++ const addGameRow = page.locator("[data-game-add-row='input']"); ++ await addGameRow.getByLabel("Game").fill("Status Bar Save"); ++ await addGameRow.getByLabel("Purpose").selectOption("Learning Game"); ++ await addGameRow.getByLabel("Status").selectOption("Ready for Testing"); ++ await addGameRow.getByRole("button", { name: "Save" }).click(); ++ ++ await expect(page.locator("[data-toolbox-status-context-type]")).toHaveText("Save State"); ++ await expect(page.locator("[data-toolbox-status-message]")).toHaveText("Created and opened Status Bar Save."); ++ await expect(page.locator("[data-toolbox-selected-game-name]")).toHaveText("Status Bar Save"); ++ await expect(page.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Learning Game"); ++ await expect(page.locator("[data-toolbox-status-bar]")).not.toContainText("Environment"); ++ ++ expectNoPageFailures(failures); ++ } finally { ++ await failures.server.close(); ++ } ++}); ++ + test("shared toolbox status bar anchors to the bottom in tool display mode", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-design/index.html"); + +@@ -156,9 +213,11 @@ test("Game Hub owner selection updates the global toolbox status bar", async ({ + await expect(page.locator("[data-toolbox-selected-game-name]")).toHaveText("Demo Game"); + await page.locator("[data-game-toggle='gravity-demo']").click(); + await expect(page.locator("[data-toolbox-selected-game-name]")).toHaveText("Gravity Demo"); +- await expect(page.locator("[data-toolbox-selected-game-meta]")).toHaveText("Capability Demo - Wireframe"); ++ await expect(page.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Capability Demo"); ++ await expect(page.locator("[data-toolbox-selected-game]")).not.toContainText("Wireframe"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-id", "gravity-demo"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-filter", "active"); ++ await expect(page.locator("[data-toolbox-status-context-type]")).toHaveText("Warning"); + await expect(page.locator("[data-toolbox-status-message]")).toContainText("Sign in to create or update Game Hub projects."); + + expectNoPageFailures(failures); +@@ -173,6 +232,8 @@ test("non-Idea Board toolbox pages show a creator-safe prompt when no Game Hub g + try { + const statusBar = page.locator("[data-toolbox-status-bar]"); + await expect(statusBar.locator("[data-toolbox-selected-game-name]")).toHaveText("No game selected"); ++ await expect(statusBar.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Game Hub owns game selection"); ++ await expect(statusBar.locator("[data-toolbox-status-context-type]")).toHaveText("Tool Action"); + await expect(statusBar.locator("[data-toolbox-status-message]")).toHaveText("Select or create a game in Game Hub before using this toolbox page."); + await expect(statusBar.locator("[data-toolbox-status-action]")).toHaveText("Select or Create in Game Hub"); + await expect(statusBar.locator("[data-toolbox-status-action]")).toHaveAttribute("href", /toolbox\/game-hub\/index\.html$/); +@@ -195,8 +256,9 @@ test("Idea Board is excluded from selected-game filtering and does not show the + try { + const statusBar = page.locator("[data-toolbox-status-bar]"); + await expect(statusBar).toBeVisible(); +- await expect(statusBar.locator("[data-toolbox-selected-game-name]")).toHaveText("Optional"); +- await expect(statusBar.locator("[data-toolbox-selected-game-meta]")).toHaveText("Idea Board can start before Game Hub creation"); ++ await expect(statusBar.locator("[data-toolbox-selected-game-name]")).toHaveText("No game selected"); ++ await expect(statusBar.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Idea Board optional"); ++ await expect(statusBar.locator("[data-toolbox-status-context-type]")).toHaveText("Tool Action"); + await expect(statusBar.locator("[data-toolbox-status-message]")).toContainText("Ready to shape ideas and notes."); + await expect(statusBar.locator("[data-toolbox-status-message]")).not.toContainText("Select or create a game"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-filter", "optional"); diff --git a/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs b/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs new file mode 100644 index 000000000..065fbce4a --- /dev/null +++ b/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs @@ -0,0 +1,275 @@ +import { expect, test } from "@playwright/test"; +import process from "node:process"; +import { MOCK_DB_KEYS } from "../../../src/dev-runtime/persistence/mock-db-store.js"; +import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; +import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; +import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; + +test.beforeEach(async ({ page }) => { + await installPlaywrightStorageIsolation(page, { + lane: "toolbox-selected-game-status-bar", + surface: "shared toolbox selected game status bar", + }); +}); + +test.afterEach(async ({ page }) => { + await workspaceV2CoverageReporter.stop(page); + await clearPlaywrightStorage(page); +}); + +test.afterAll(async () => { + await workspaceV2CoverageReporter.writeReport(); +}); + +function restoreEnvValue(key, value) { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} + +async function openRepoPage(page, pathName, options = {}) { + const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; + const closeServer = server.close.bind(server); + server.close = async () => { + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); + await closeServer(); + }; + const failedRequests = []; + const pageErrors = []; + const consoleErrors = []; + + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + page.on("response", (response) => { + if (response.status() >= 400) { + failedRequests.push(`${response.status()} ${response.url()}`); + } + }); + page.on("requestfailed", (request) => { + failedRequests.push(`FAILED ${request.url()}`); + }); + + if (options.noSelectedGame) { + await page.route("**/api/toolbox/game-hub/repositories/*/methods/getActiveGame", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: { result: null }, + ok: true, + }), + }); + }); + } + if (options.session) { + const userKey = options.session.userKey || MOCK_DB_KEYS.users.user1; + await page.route("**/api/session/current", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: { + authenticated: true, + displayName: options.session.displayName || "User 1", + roleSlugs: options.session.roleSlugs || ["creator"], + userKey, + }, + ok: true, + }), + }); + }); + await page.request.post(`${server.baseUrl}/api/session/user`, { + data: { userKey }, + }); + } + + await workspaceV2CoverageReporter.start(page); + await page.goto(`${server.baseUrl}${pathName}`, { waitUntil: "networkidle" }); + return { consoleErrors, failedRequests, pageErrors, server }; +} + +function expectNoPageFailures(failures) { + expect(failures.failedRequests).toEqual([]); + expect(failures.pageErrors).toEqual([]); + expect(failures.consoleErrors).toEqual([]); +} + +function creatorSession() { + return { + displayName: "User 1", + roleSlugs: ["creator"], + userKey: MOCK_DB_KEYS.users.user1, + }; +} + +async function statusBarSnapshot(page) { + return page.locator("[data-toolbox-status-bar]").evaluate((bar) => { + const footer = document.querySelector("footer.footer"); + const position = getComputedStyle(bar).position; + const barBox = bar.getBoundingClientRect(); + const footerBox = footer?.getBoundingClientRect(); + return { + bottomGap: Math.round(window.innerHeight - barBox.bottom), + dataset: { ...bar.dataset }, + filter: document.body.dataset.toolboxSelectedGameFilter || "", + footerFollowsBar: footer ? Boolean(bar.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING) : false, + gameId: document.body.dataset.toolboxSelectedGameId || "", + gameText: bar.querySelector("[data-toolbox-selected-game]")?.textContent.replace(/\s+/g, " ").trim() || "", + messageText: bar.querySelector("[data-toolbox-status-center]")?.textContent.replace(/\s+/g, " ").trim() || "", + position, + topBeforeFooter: footerBox ? barBox.bottom <= footerBox.top + 1 : false, + }; + }); +} + +test("shared toolbox status bar shows selected Game Hub game above the footer", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-design/index.html"); + + try { + const statusBar = page.locator("[data-toolbox-status-bar]"); + await expect(statusBar).toBeVisible(); + await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); + await expect(statusBar).not.toContainText("Environment"); + await expect(statusBar.locator("[data-toolbox-selected-game-name-label]")).toHaveText("Selected Game Name"); + await expect(statusBar.locator("[data-toolbox-selected-game-name]")).toHaveText("Demo Game"); + await expect(statusBar.locator("[data-toolbox-selected-game-purpose-label]")).toHaveText("Selected Game Purpose"); + await expect(statusBar.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Game"); + await expect(statusBar.locator("[data-toolbox-selected-game]")).not.toContainText("Under Construction"); + await expect(statusBar.locator("[data-toolbox-status-context-type]")).toHaveText("Tool Action"); + await expect(statusBar.locator("[data-toolbox-status-message]")).toContainText("Game Design mock repository ready."); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-id", "demo-game"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-filter", "active"); + + const snapshot = await statusBarSnapshot(page); + expect(snapshot.footerFollowsBar).toBe(true); + expect(snapshot.topBeforeFooter).toBe(true); + expect(snapshot.position).not.toBe("fixed"); + expect(snapshot.dataset.selectedGameState).toBe("active"); + expect(snapshot.dataset.selectedGameRequired).toBe("true"); + + expectNoPageFailures(failures); + } finally { + await failures.server.close(); + } +}); + +test("shared toolbox status bar center reports save state after Game Hub saves", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-hub/index.html", { session: creatorSession() }); + + try { + await page.getByRole("button", { name: "Add Game" }).click(); + const addGameRow = page.locator("[data-game-add-row='input']"); + await addGameRow.getByLabel("Game").fill("Status Bar Save"); + await addGameRow.getByLabel("Purpose").selectOption("Learning Game"); + await addGameRow.getByLabel("Status").selectOption("Ready for Testing"); + await addGameRow.getByRole("button", { name: "Save" }).click(); + + await expect(page.locator("[data-toolbox-status-context-type]")).toHaveText("Save State"); + await expect(page.locator("[data-toolbox-status-message]")).toHaveText("Created and opened Status Bar Save."); + await expect(page.locator("[data-toolbox-selected-game-name]")).toHaveText("Status Bar Save"); + await expect(page.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Learning Game"); + await expect(page.locator("[data-toolbox-status-bar]")).not.toContainText("Environment"); + + expectNoPageFailures(failures); + } finally { + await failures.server.close(); + } +}); + +test("shared toolbox status bar anchors to the bottom in tool display mode", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-design/index.html"); + + try { + await expect(page.locator("[data-toolbox-status-bar]")).toBeVisible(); + await page.locator("#toolDisplayMode summary").click(); + await expect(page.locator("body")).toHaveClass(/tool-focus-mode/); + + const snapshot = await statusBarSnapshot(page); + expect(snapshot.position).toBe("fixed"); + expect(Math.abs(snapshot.bottomGap)).toBeLessThanOrEqual(2); + expect(snapshot.gameText).toContain("Demo Game"); + + expectNoPageFailures(failures); + } finally { + await failures.server.close(); + } +}); + +test("Game Hub owner selection updates the global toolbox status bar", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-hub/index.html"); + + try { + await expect(page.locator("[data-toolbox-selected-game-name]")).toHaveText("Demo Game"); + await page.locator("[data-game-toggle='gravity-demo']").click(); + await expect(page.locator("[data-toolbox-selected-game-name]")).toHaveText("Gravity Demo"); + await expect(page.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Capability Demo"); + await expect(page.locator("[data-toolbox-selected-game]")).not.toContainText("Wireframe"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-id", "gravity-demo"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-filter", "active"); + await expect(page.locator("[data-toolbox-status-context-type]")).toHaveText("Warning"); + await expect(page.locator("[data-toolbox-status-message]")).toContainText("Sign in to create or update Game Hub projects."); + + expectNoPageFailures(failures); + } finally { + await failures.server.close(); + } +}); + +test("non-Idea Board toolbox pages show a creator-safe prompt when no Game Hub game is selected", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/game-design/index.html", { noSelectedGame: true }); + + try { + const statusBar = page.locator("[data-toolbox-status-bar]"); + await expect(statusBar.locator("[data-toolbox-selected-game-name]")).toHaveText("No game selected"); + await expect(statusBar.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Game Hub owns game selection"); + await expect(statusBar.locator("[data-toolbox-status-context-type]")).toHaveText("Tool Action"); + await expect(statusBar.locator("[data-toolbox-status-message]")).toHaveText("Select or create a game in Game Hub before using this toolbox page."); + await expect(statusBar.locator("[data-toolbox-status-action]")).toHaveText("Select or Create in Game Hub"); + await expect(statusBar.locator("[data-toolbox-status-action]")).toHaveAttribute("href", /toolbox\/game-hub\/index\.html$/); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-filter", "missing"); + await expect(page.locator("body")).not.toHaveAttribute("data-toolbox-selected-game-id", /.+/); + + const snapshot = await statusBarSnapshot(page); + expect(snapshot.dataset.selectedGameRequired).toBe("true"); + expect(snapshot.dataset.selectedGameState).toBe("missing"); + + expectNoPageFailures(failures); + } finally { + await failures.server.close(); + } +}); + +test("Idea Board is excluded from selected-game filtering and does not show the missing-game prompt", async ({ page }) => { + const failures = await openRepoPage(page, "/toolbox/idea-board/index.html", { noSelectedGame: true }); + + try { + const statusBar = page.locator("[data-toolbox-status-bar]"); + await expect(statusBar).toBeVisible(); + await expect(statusBar.locator("[data-toolbox-selected-game-name]")).toHaveText("No game selected"); + await expect(statusBar.locator("[data-toolbox-selected-game-purpose]")).toHaveText("Idea Board optional"); + await expect(statusBar.locator("[data-toolbox-status-context-type]")).toHaveText("Tool Action"); + await expect(statusBar.locator("[data-toolbox-status-message]")).toContainText("Ready to shape ideas and notes."); + await expect(statusBar.locator("[data-toolbox-status-message]")).not.toContainText("Select or create a game"); + await expect(page.locator("body")).toHaveAttribute("data-toolbox-selected-game-filter", "optional"); + await expect(page.locator("body")).not.toHaveClass(/toolbox-selected-game-missing/); + + const snapshot = await statusBarSnapshot(page); + expect(snapshot.dataset.selectedGameRequired).toBe("false"); + expect(snapshot.dataset.selectedGameState).toBe("missing"); + + expectNoPageFailures(failures); + } finally { + await failures.server.close(); + } +}); diff --git a/toolbox/game-hub/game-hub.js b/toolbox/game-hub/game-hub.js index b04c26882..b59da1c43 100644 --- a/toolbox/game-hub/game-hub.js +++ b/toolbox/game-hub/game-hub.js @@ -42,6 +42,15 @@ function setStatusLog(message) { setText(elements.statusLog, message); } +function notifySelectedGameChanged(activeGame) { + window.dispatchEvent(new CustomEvent("gamefoundry:toolbox-selected-game-changed", { + detail: { + selectedGameId: activeGame?.id || "", + source: "game-hub", + }, + })); +} + function isRecord(value) { return Boolean(value && typeof value === "object"); } @@ -654,6 +663,7 @@ function renderWorkspace() { renderChecklist(progress); renderGameTableStatus(); refreshSaveControls(activeGame); + notifySelectedGameChanged(activeGame); } function readGameRowFields(row) {