diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index 7c10833d2..3fb4228d0 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,34 +1,54 @@ -# git status --short -M docs_build/dev/reports/codex_changed_files.txt - M docs_build/dev/reports/codex_review.diff - M docs_build/dev/reports/coverage_changed_js_guardrail.txt - M docs_build/dev/reports/playwright_v8_coverage_report.txt - M tests/playwright/tools/TextToSpeechFunctional.spec.mjs - M tests/tools/Text2SpeechShell.test.mjs - M toolbox/text-to-speech/index.html - M toolbox/text-to-speech/text2speech.js -?? docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md -?? docs_build/dev/reports/PR_26171_067-manual-validation-notes.md -?? docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md -?? docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md -?? docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md -?? docs_build/dev/reports/PR_26171_067-validation.md +PR_26171_044-idea-board-game-hub-project-flow -# git ls-files --others --exclude-standard -docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md -docs_build/dev/reports/PR_26171_067-manual-validation-notes.md -docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md -docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md -docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md -docs_build/dev/reports/PR_26171_067-validation.md +Changed files: +- docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/PLAN_PR.md +- docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/BUILD_PR.md +- docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/APPLY_PR.md +- docs_build/dev/reports/codex_review.diff +- docs_build/dev/reports/codex_changed_files.txt +- src/dev-runtime/persistence/tool-repositories/game-workspace-mock-repository.js +- tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs +- tests/playwright/tools/IdeaBoardTableNotes.spec.mjs +- tests/playwright/tools/ToolboxRoutePages.spec.mjs +- toolbox/game-workspace/game-workspace.js +- toolbox/game-workspace/index.html +- toolbox/idea-board/index.html +- toolbox/idea-board/index.js -# git diff --stat -docs_build/dev/reports/codex_changed_files.txt | 75 +- - docs_build/dev/reports/codex_review.diff | 2766 +++++++++----------- - .../dev/reports/coverage_changed_js_guardrail.txt | 9 +- - .../dev/reports/playwright_v8_coverage_report.txt | 27 +- - .../tools/TextToSpeechFunctional.spec.mjs | 42 +- - tests/tools/Text2SpeechShell.test.mjs | 48 + - toolbox/text-to-speech/index.html | 40 +- - toolbox/text-to-speech/text2speech.js | 728 +++++- - 8 files changed, 2153 insertions(+), 1582 deletions(-) \ No newline at end of file +Requirement PASS evidence: +- PASS: Create Project appears only for Ready ideas. Evidence: Idea Board actions remain Edit/Create Project/Delete for Ready rows and switch to Open in Game Hub/Archive after creation in targeted Playwright. +- PASS: Create Project uses the shared Game Hub project contract. Evidence: Idea Board calls the existing game-workspace server repository client createGame method; Playwright asserts the createGame repository request occurs. +- PASS: Create Project sets Idea status to Project. Evidence: targeted Playwright asserts the row status becomes Project. +- PASS: Project ideas and notes are read-only. Evidence: targeted Playwright asserts no Edit/Delete/Add Note/note actions remain for the Project row. +- PASS: Project row actions are Open in Game Hub and Archive. Evidence: targeted Playwright asserts exact action labels. +- PASS: Project ideas cannot be edited or deleted while Project. Evidence: Project rows do not render Edit/Delete actions and deletion guard remains active. +- PASS: Project ideas cannot add/edit/delete notes. Evidence: expanded Project notes render without Add Note or note actions. +- PASS: Archived ideas remain hidden by default and show Restore/Delete when visible. Evidence: targeted Playwright archives, shows Archived through the filter, and asserts Restore/Delete. +- PASS: Game Hub shows creator-facing Project Information. Evidence: Game Hub HTML/JS replaces internal record display with Project Information and targeted Game Hub Playwright passes. +- PASS: Game Hub shows read-only Source Idea fields. Evidence: Idea Board-to-Game Hub Playwright asserts Idea, Pitch, and Notes from the source idea render in Game Hub. +- PASS: Game Hub avoids internal IDs, DB/API/mock/debug/seed wording in creator-facing display. Evidence: touched runtime files pass creator-visible text scan and targeted Playwright checks the navigated Game Hub main content. +- PASS: Open in Game Hub navigates to the related project. Evidence: targeted Playwright waits for /toolbox/game-workspace/index.html?game=lantern-reef-* and asserts the linked project renders. +- PASS: No Game Journey expansion beyond existing link/reference. Evidence: implementation does not add Game Journey creation or new journey wiring. + +Validation evidence: +- PASS: node --check toolbox/idea-board/index.js +- PASS: node --check toolbox/game-workspace/game-workspace.js +- PASS: node --check toolbox/game-workspace/game-workspace-api-client.js +- PASS: node --check src/dev-runtime/persistence/tool-repositories/game-workspace-mock-repository.js +- PASS: node --check tests/playwright/tools/IdeaBoardTableNotes.spec.mjs +- PASS: node --check tests/playwright/tools/ToolboxRoutePages.spec.mjs +- PASS: node --check tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs +- PASS: npx playwright test tests/playwright/tools/IdeaBoardTableNotes.spec.mjs --project=playwright --workers=1 --reporter=line +- PASS: npx playwright test tests/playwright/tools/ToolboxRoutePages.spec.mjs --project=playwright --workers=1 --reporter=line --grep "Idea Board launches" +- PASS: npx playwright test tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs --project=playwright --workers=1 --reporter=line --grep "Game Hub" +- PASS: npm run test:workspace-v2 +- PASS: git diff --check + +Git workflow: +- Current branch: codex/pr-26171-044-idea-board-game-hub-project-flow +- Created branch: codex/pr-26171-044-idea-board-game-hub-project-flow +- Commit hash: 2a38796f81aae2dceb4151095b8f89a276cd2d32 +- Push result: pushed to origin/codex/pr-26171-044-idea-board-game-hub-project-flow +- PR URL: https://github.com/ToolboxAid/HTML-JavaScript-Gaming/pull/22 +- Merge result: to be reported after merge +- Final main commit: to be reported after returning to main and pulling latest diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 85677add8..cffbc3823 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,4245 +1,1210 @@ -diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt -index e0820a401..33a70c4b4 100644 ---- a/docs_build/dev/reports/codex_changed_files.txt -+++ b/docs_build/dev/reports/codex_changed_files.txt -@@ -1,53 +1,30 @@ --# Codex Changed Files - PR_26171_065-message-studio-parent-child-table-foundation -- --## Git Status Short --```text -+# git status --short - M docs_build/dev/reports/coverage_changed_js_guardrail.txt - M docs_build/dev/reports/playwright_v8_coverage_report.txt -- M tests/playwright/tools/MessagesTool.spec.mjs -- M toolbox/messages/index.html -- M toolbox/messages/message-tts-service-registry.js -- M toolbox/messages/messages.js --?? docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md --?? docs_build/dev/reports/PR_26171_065-manual-validation-notes.md --?? docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md --?? docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md --?? docs_build/dev/reports/PR_26171_065-validation.md --``` -- --## Scoped Diff Stat --```text --.../dev/reports/coverage_changed_js_guardrail.txt | 2 + -- .../dev/reports/playwright_v8_coverage_report.txt | 43 +- -- tests/playwright/tools/MessagesTool.spec.mjs | 317 +++++--------- -- toolbox/messages/index.html | 29 +- -- toolbox/messages/message-tts-service-registry.js | 8 +- -- toolbox/messages/messages.js | 486 ++++++++++++--------- -- 6 files changed, 415 insertions(+), 470 deletions(-) --``` -- --## Changed Files --- toolbox/messages/index.html --- toolbox/messages/messages.js --- toolbox/messages/message-tts-service-registry.js --- tests/playwright/tools/MessagesTool.spec.mjs --- docs_build/dev/reports/coverage_changed_js_guardrail.txt --- docs_build/dev/reports/playwright_v8_coverage_report.txt --- docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md --- docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md --- docs_build/dev/reports/PR_26171_065-validation.md --- docs_build/dev/reports/PR_26171_065-manual-validation-notes.md --- docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md --- docs_build/dev/reports/codex_review.diff --- docs_build/dev/reports/codex_changed_files.txt -+ M tests/playwright/tools/TextToSpeechFunctional.spec.mjs -+ M tests/tools/Text2SpeechShell.test.mjs -+ M toolbox/text-to-speech/index.html -+ M toolbox/text-to-speech/text2speech.js -+?? docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md -+?? docs_build/dev/reports/PR_26171_067-manual-validation-notes.md -+?? docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md -+?? docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md -+?? docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md -+?? docs_build/dev/reports/PR_26171_067-validation.md - --## Validation --- PASS: `node --check toolbox\messages\messages.js`. --- PASS: `node --check toolbox\messages\message-tts-service-registry.js`. --- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs`. --- PASS: HTML inline style/script/event scan for `toolbox/messages/index.html`. --- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. --- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). -+# git ls-files --others --exclude-standard -+docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md -+docs_build/dev/reports/PR_26171_067-manual-validation-notes.md -+docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md -+docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md -+docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md -+docs_build/dev/reports/PR_26171_067-validation.md - --## ZIP --- Path: `tmp/PR_26171_065-message-studio-parent-child-table-foundation_delta.zip`. -+# git diff --stat -+.../dev/reports/coverage_changed_js_guardrail.txt | 9 +- -+ .../dev/reports/playwright_v8_coverage_report.txt | 27 +- -+ .../tools/TextToSpeechFunctional.spec.mjs | 42 +- -+ tests/tools/Text2SpeechShell.test.mjs | 48 ++ -+ toolbox/text-to-speech/index.html | 40 +- -+ toolbox/text-to-speech/text2speech.js | 728 ++++++++++++++++++++- -+ 6 files changed, 871 insertions(+), 23 deletions(-) -\ No newline at end of file -diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff -index 39d7f84a5..0bec7d9bf 100644 ---- a/docs_build/dev/reports/codex_review.diff -+++ b/docs_build/dev/reports/codex_review.diff -@@ -1,1576 +1,1322 @@ --diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt --index 15213f1d6..e0820a401 100644 ----- a/docs_build/dev/reports/codex_changed_files.txt --+++ b/docs_build/dev/reports/codex_changed_files.txt --@@ -1,78 +1,53 @@ ---# PR_26171_042 Codex Changed Files Report --- ---## Instruction Compliance Gate --- ---- Current branch before execution: `main` ---- Required execution branch before PR branch: `main` ---- Branch validation: PASS ---- Clean repository before branch creation: PASS ---- PR owner/parity: PASS, `042` is even and Idea/Tool Display navigation scope maps to PC / Environment 1. ---- Implementation path: PASS, active Idea Board and Theme V2 files only. ---- Validation scope: PASS, targeted Idea Board, targeted Toolbox route for Idea Board, and workspace contract lane because shared Tool Display Mode behavior changed. ---- Required reports: PASS, `docs_build/dev/reports/codex_review.diff` and this report are updated. ---- ZIP requirement: PASS, `tmp/PR_26171_042-idea-board-navigation-fallback-cleanup_delta.zip` is required and produced before final delivery. --- ---## Git Workflow Fields --- ---- Created branch: `codex/pr-26171-042-idea-board-navigation-fallback-cleanup` ---- Push result: PASS, branch pushed to `origin/codex/pr-26171-042-idea-board-navigation-fallback-cleanup`. ---- PR URL: `https://github.com/ToolboxAid/HTML-JavaScript-Gaming/pull/19` ---- Merge result: recorded in final Codex delivery after GitHub merge returns the merge SHA. ---- Final main commit: recorded in final Codex delivery after returning to `main` and pulling latest. ---- Conflict resolution: PASS, merged latest `origin/main` (`9df4942226b0c1a25cfc9567040fc237d90df8f9`) and resolved conflicts only in singleton generated report artifacts. --- ---## Scoped Files --- ---- `assets/theme-v2/js/tool-display-mode.js` ---- `tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` ---- `tests/playwright/tools/ToolboxRoutePages.spec.mjs` ---- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md` ---- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md` ---- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md` ---- `docs_build/dev/reports/coverage_changed_js_guardrail.txt` ---- `docs_build/dev/reports/playwright_v8_coverage_report.txt` ---- `docs_build/dev/reports/codex_review.diff` ---- `docs_build/dev/reports/codex_changed_files.txt` --- ---## Requirement Evidence --- ---- PASS: Removed creator-visible Tool Display Mode navigation diagnostic fallback. `tool-display-mode.js` now logs the navigation load failure to console only and appends no status paragraph. ---- PASS: Removed visible message `Tool navigation is temporarily unavailable. Refresh the page or try again shortly.` ---- PASS: Idea Board stays usable when registry-backed navigation cannot load. Static/no-registry Playwright path expands notes and adds a note successfully. ---- PASS: Creator-facing UI does not mention server, API, local server, port, registry, snapshot, or implementation details in the navigation fallback area. ---- PASS: Navigation failure does not affect Idea Board table functionality. ---- PASS: API-backed local route validated by targeted Idea Board and Toolbox route Playwright. ---- PASS: Static/no-registry route behavior validated by targeted Idea Board Playwright with the registry snapshot returning no data. ---- PASS: Optional previous/next navigation is omitted when unavailable. ---- PASS: Idea Board lifecycle, Show filter, Create Project, Archive, chevron, and table row editing behavior were not changed. --+# Codex Changed Files - PR_26171_065-message-studio-parent-child-table-foundation --+ --+## Git Status Short --+```text --+M docs_build/dev/reports/coverage_changed_js_guardrail.txt --+ M docs_build/dev/reports/playwright_v8_coverage_report.txt --+ M tests/playwright/tools/MessagesTool.spec.mjs --+ M toolbox/messages/index.html --+ M toolbox/messages/message-tts-service-registry.js --+ M toolbox/messages/messages.js --+?? docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md --+?? docs_build/dev/reports/PR_26171_065-manual-validation-notes.md --+?? docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md --+?? docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md --+?? docs_build/dev/reports/PR_26171_065-validation.md --+``` --+ --+## Scoped Diff Stat --+```text --+.../dev/reports/coverage_changed_js_guardrail.txt | 2 + --+ .../dev/reports/playwright_v8_coverage_report.txt | 43 +- --+ tests/playwright/tools/MessagesTool.spec.mjs | 317 +++++--------- --+ toolbox/messages/index.html | 29 +- --+ toolbox/messages/message-tts-service-registry.js | 8 +- --+ toolbox/messages/messages.js | 486 ++++++++++++--------- --+ 6 files changed, 415 insertions(+), 470 deletions(-) --+``` --+ --+## Changed Files --+- toolbox/messages/index.html --+- toolbox/messages/messages.js --+- toolbox/messages/message-tts-service-registry.js --+- tests/playwright/tools/MessagesTool.spec.mjs --+- docs_build/dev/reports/coverage_changed_js_guardrail.txt --+- docs_build/dev/reports/playwright_v8_coverage_report.txt --+- docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md --+- docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md --+- docs_build/dev/reports/PR_26171_065-validation.md --+- docs_build/dev/reports/PR_26171_065-manual-validation-notes.md --+- docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md --+- docs_build/dev/reports/codex_review.diff --+- docs_build/dev/reports/codex_changed_files.txt -- -- ## Validation --- ---- PASS: `node --check assets/theme-v2/js/tool-display-mode.js` ---- PASS: `node --check toolbox/idea-board/index.js` ---- PASS: `node --check tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` ---- PASS: `node --check tests/playwright/tools/ToolboxRoutePages.spec.mjs` ---- PASS: `npx playwright test tests/playwright/tools/IdeaBoardTableNotes.spec.mjs --project=playwright --workers=1 --reporter=line --timeout=90000` ---- PASS: `npx playwright test tests/playwright/tools/ToolboxRoutePages.spec.mjs --project=playwright --workers=1 --reporter=line -g "Idea Board launches" --timeout=90000` ---- PASS: `npm run test:workspace-v2` ---- PASS: `git diff --check` ---- SKIP: Full samples smoke, per user instruction. --- ---## Coverage Evidence --- ---- PASS: `docs_build/dev/reports/playwright_v8_coverage_report.txt` updated. ---- PASS: `docs_build/dev/reports/coverage_changed_js_guardrail.txt` updated. ---- PASS: Changed runtime JS coverage lists `assets/theme-v2/js/tool-display-mode.js` at 64% advisory function coverage. --- ---## ZIP Contents --- ---- `assets/theme-v2/js/tool-display-mode.js` ---- `docs_build/dev/reports/codex_changed_files.txt` ---- `docs_build/dev/reports/codex_review.diff` ---- `docs_build/dev/reports/coverage_changed_js_guardrail.txt` ---- `docs_build/dev/reports/playwright_v8_coverage_report.txt` ---- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md` ---- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md` ---- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md` ---- `tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` ---- `tests/playwright/tools/ToolboxRoutePages.spec.mjs` --+- PASS: `node --check toolbox\messages\messages.js`. --+- PASS: `node --check toolbox\messages\message-tts-service-registry.js`. --+- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs`. --+- PASS: HTML inline style/script/event scan for `toolbox/messages/index.html`. --+- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. --+- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). --+ --+## ZIP --+- Path: `tmp/PR_26171_065-message-studio-parent-child-table-foundation_delta.zip`. - diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt --index 242796c4f..6fbeaba58 100644 -+index 6fbeaba58..21de9adb6 100644 - --- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt - +++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt --@@ -7,6 +7,8 @@ Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. -- -+@@ -6,9 +6,10 @@ Missing changed runtime JS files are WARN, not FAIL. -+ Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. +diff --git a/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/APPLY_PR.md b/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/APPLY_PR.md +new file mode 100644 +index 000000000..2fba95562 +--- /dev/null ++++ b/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/APPLY_PR.md +@@ -0,0 +1,17 @@ ++# PR_26171_044-idea-board-game-hub-project-flow Apply ++ ++## Apply Steps ++ ++1. Start from clean `main`. ++2. Pull latest `main`. ++3. Create `codex/pr-26171-044-idea-board-game-hub-project-flow`. ++4. Implement only the BUILD scope. ++5. Run requested validation. ++6. Stage scoped files only. ++7. Commit. ++8. Push branch. ++9. Create PR. ++10. Resolve conflicts if encountered and rerun validation. ++11. Merge after validation passes. ++12. Return to `main` and pull latest. ++13. Produce final report with PR URL, merge result, final main commit, ZIP path, ZIP size, ZIP contents, and requirement PASS evidence. +diff --git a/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/BUILD_PR.md b/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/BUILD_PR.md +new file mode 100644 +index 000000000..228f7703f +--- /dev/null ++++ b/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/BUILD_PR.md +@@ -0,0 +1,56 @@ ++# PR_26171_044-idea-board-game-hub-project-flow Build + - Changed runtime JS files considered: -- (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 --+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 --+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 -- -+-(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 -+-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -+-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 -++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 ++## Source Of Truth + - Guardrail warnings: -- (100%) none - no changed runtime JS coverage warnings -+-(100%) none - no changed runtime JS coverage warnings -++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file missing from coverage; advisory only -++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only - diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt --index 17eb943d2..578a19ddc 100644 -+index 578a19ddc..49e94f0d7 100644 - --- a/docs_build/dev/reports/playwright_v8_coverage_report.txt - +++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt --@@ -12,45 +12,34 @@ Note: entry percentages use function coverage when available, otherwise line cov -+@@ -12,34 +12,33 @@ Note: entry percentages use function coverage when available, otherwise line cov - Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. -- ++Use the user request for `PR_26171_044-idea-board-game-hub-project-flow`, `docs_build/dev/PROJECT_INSTRUCTIONS.md`, `docs_build/dev/PROJECT_MULTI_PC.txt`, and this BUILD doc. + - Exercised tool entry points detected: ---(72%) Toolbox Index - exercised 10 runtime JS files --+(83%) Toolbox Index - exercised 4 runtime JS files -+-(83%) Toolbox Index - exercised 4 runtime JS files -++(80%) Toolbox Index - exercised 2 runtime JS files - (0%) Tool Template V2 - not exercised by this Playwright run ---(61%) Theme V2 Shared JS - exercised 7 runtime JS files --+(56%) Theme V2 Shared JS - exercised 2 runtime JS files -- -+ (56%) Theme V2 Shared JS - exercised 2 runtime JS files ++## Singular Purpose + - Changed runtime JS files covered: -- (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 --+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 --+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 -- -+-(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 -+-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -+-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 -++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 ++Complete the Idea Board to Game Hub creator project handoff. + - Files with executed line/function counts where available: ---(14%) assets/theme-v2/js/account-auth-service.js - executed lines 64/64; executed functions 1/7 ---(14%) assets/theme-v2/js/admin-setup-actions.js - executed lines 55/55; executed functions 1/7 ---(18%) assets/theme-v2/js/account-page-data.js - executed lines 150/150; executed functions 3/17 ---(20%) assets/theme-v2/js/admin-owner-navigation.js - executed lines 58/58; executed functions 2/10 ---(25%) src/api/admin-owner-navigation.js - executed lines 42/42; executed functions 1/4 ---(25%) src/api/session-api-client.js - executed lines 68/68; executed functions 3/12 ---(29%) src/engine/input/NormalizedInputRegistry.js - executed lines 341/341; executed functions 6/21 ---(33%) src/api/admin-setup-api-client.js - executed lines 13/13; executed functions 1/3 ---(33%) src/api/toolbox-votes-api-client.js - executed lines 46/46; executed functions 2/6 ---(56%) toolbox/colors/colors.js - executed lines 1848/1848; executed functions 115/204 ---(58%) src/api/server-api-client.js - executed lines 167/167; executed functions 11/19 --+(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 --+(36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 --+(38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 --+(54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 --+(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 -+-(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 -+ (36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 -+ (38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 -+ (54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 -+-(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 - (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 ---(64%) toolbox/controls/controls.js - executed lines 610/610; executed functions 36/56 ---(65%) src/api/public-config-client.js - executed lines 209/209; executed functions 17/26 ---(67%) src/api/game-journey-completion-api-client.js - executed lines 15/15; executed functions 2/3 ---(74%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 67/90 ---(75%) toolbox/game-workspace/game-workspace.js - executed lines 458/458; executed functions 33/44 ---(89%) toolbox/tools-page-accordions.js - executed lines 1156/1156; executed functions 105/118 ---(90%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 26/29 ---(91%) toolbox/game-design/game-design.js - executed lines 254/254; executed functions 21/23 ---(94%) assets/theme-v2/js/marketplace-page.js - executed lines 170/170; executed functions 16/17 ---(100%) src/api/marketplace-api-client.js - executed lines 16/16; executed functions 3/3 ---(100%) toolbox/colors/palette-api-client.js - executed lines 28/28; executed functions 4/4 ---(100%) toolbox/controls/controls-api-client.js - executed lines 33/33; executed functions 5/5 ---(100%) toolbox/game-design/game-design-api-client.js - executed lines 13/13; executed functions 2/2 ---(100%) toolbox/game-workspace/game-workspace-api-client.js - executed lines 20/20; executed functions 3/3 --+(76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 --+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 --+(100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 --+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 -- -++(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 -+ (76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 -+-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 -+ (100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 -+-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 ++## Exact Targets + - Uncovered or low-coverage changed JS files: -- (100%) none - no low-coverage changed runtime JS files -- -+-(100%) none - no low-coverage changed runtime JS files -++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: uncovered changed runtime JS file; advisory only -++(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only ++- `toolbox/idea-board/index.js` ++- `toolbox/game-workspace/game-workspace.js` ++- Existing targeted Playwright specs for Idea Board and Game Hub/Game Workspace. ++- Any smallest existing shared project or handoff contract file required by the flow. ++- `docs_build/dev/reports/codex_review.diff` ++- `docs_build/dev/reports/codex_changed_files.txt` + - Changed JS files considered: -- (0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage --+(0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage -- (0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage -- (64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage --+(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage --+(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage --diff --git a/tests/playwright/tools/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs --index b120f19a3..451c4439f 100644 ----- a/tests/playwright/tools/MessagesTool.spec.mjs --+++ b/tests/playwright/tools/MessagesTool.spec.mjs --@@ -31,7 +31,7 @@ async function jsonRequest(url, options = {}) { -- test.beforeEach(async ({ page }) => { -- await installPlaywrightStorageIsolation(page, { -- lane: "messages-tool", --- surface: "Message Studio Local API, legacy SQLite technical debt adapter, and Theme V2 tool", --+ surface: "Message Studio parent/child table, Local API, and Theme V2 tool", -- }); -+-(0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage -+ (0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage -+-(0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage -+-(64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage -+-(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage -+-(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage -++(0%) tests/playwright/tools/TextToSpeechFunctional.spec.mjs - changed JS file not collected as browser runtime coverage -++(0%) tests/tools/Text2SpeechShell.test.mjs - changed JS file not collected as browser runtime coverage -++(0%) toolbox/messages/message-tts-service-registry.js - changed JS file not collected as browser runtime coverage -++(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage -++(80%) toolbox/text-to-speech/text2speech.js - changed JS file with browser V8 coverage -+diff --git a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs -+index 17e2c0b62..8c964fecc 100644 -+--- a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs -++++ b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs -+@@ -106,7 +106,45 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as ++## Requirements + -+ await expect(page.locator("[data-tts-voice-select]")).toContainText("Arcade Voice"); -+ await expect(page.locator("[data-tts-voice-count]")).toHaveText("2"); -+- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Ready"); -++ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); -++ await expect(page.locator("[data-tts-emotion-count]")).toHaveText("3"); -++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Default Balanced Profile"); -++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Man Profile 1"); -++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Woman Profile 2"); -++ await expect(page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).getByRole("button", { name: "Delete" })).toBeDisabled(); -++ await page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).click(); -++ await expect(page.getByRole("heading", { name: "Emotion Settings" })).toBeVisible(); -++ await expect(page.getByRole("columnheader", { name: "Emotion", exact: true })).toBeVisible(); -++ await expect(page.getByRole("columnheader", { name: "SSML-like Preset" })).toBeVisible(); -++ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Delete" })).toBeDisabled(); -++ -++ await page.getByRole("button", { name: "Add Profile" }).click(); -++ await expect(page.locator("[data-tts-profile-editor='__new__']")).toBeVisible(); -++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Creature Profile"); -++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("neutral"); -++ await page.locator("[data-tts-commit-profile='__new__']").click(); -++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile."); -++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Creature Profile"); -++ await page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile" }).getByRole("button", { name: "Edit Profile" }).click(); -++ await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Creature Profile Updated"); -++ await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); -++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile Updated."); -++ await page.getByRole("button", { name: "Add Emotion" }).click(); -++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); -++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]").fill("1.2"); -++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]").fill("1.1"); -++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]").fill("0.8"); -++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-ssml-preset]").selectOption("whisper-ish"); -++ await page.locator("[data-tts-commit-emotion='__new__']").click(); -++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); -++ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" })).toContainText("1.2"); -++ await page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" }).getByRole("button", { name: "Edit Emotion" }).click(); -++ await page.locator("[data-tts-emotion-editor] [data-tts-emotion-volume]").fill("0.7"); -++ await page.locator("[data-tts-emotion-editor] [data-tts-commit-emotion]").click(); -++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); -++ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"contractVersion\": \"tts-profile-emotion-v1\""); -++ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"name\": \"Creature Profile Updated\""); -++ -+ await expect(page.locator("[data-tts-gender-select]")).toBeVisible(); -+ await expect(page.locator("[data-tts-language-select]")).toBeVisible(); -+ await expect(page.locator("[data-tts-age-select]")).toBeVisible(); -+@@ -173,7 +211,7 @@ test("Text To Speech shows actionable error when browser speech synthesis is una -+ const failures = await openTextToSpeechPage(page, { speechAvailable: false }); -+ try { -+ await expect(page.getByRole("heading", { level: 1, name: "Text To Speech" })).toBeVisible(); -+- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Unavailable"); -++ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); -+ await expect(page.locator("[data-tts-engine-status]")).toContainText("SpeechSynthesis is unavailable"); -+ await expect(page.locator("[data-tts-status]")).toContainText("Use a browser with Web Speech API support"); -+ await expect(page.locator("[data-tts-voice-select]")).toContainText("No browser voices available"); -+diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs -+index fbc6ac8e9..51e4f019f 100644 -+--- a/tests/tools/Text2SpeechShell.test.mjs -++++ b/tests/tools/Text2SpeechShell.test.mjs -+@@ -3,9 +3,14 @@ import test from "node:test"; ++- Create Project appears only for Ready ideas. ++- Clicking Create Project creates or links a Game Hub project using an existing/shared project contract if available. ++- Clicking Create Project sets Idea status to Project. ++- Project ideas and their notes become read-only. ++- Project row actions are Open in Game Hub and Archive. ++- Project ideas cannot be edited or deleted. ++- Project ideas cannot add, edit, or delete notes. ++- Archived ideas remain hidden by default through existing filter behavior and show Restore and Delete when visible. ++- Game Hub displays creator-facing project data only. ++- Game Hub shows Project Information. ++- Game Hub shows a read-only Source Idea section with Idea, Pitch, and Notes. ++- Game Hub must not show internal IDs, DB/API/mock/debug/seed wording, or implementation details. ++- Open in Game Hub from Idea Board navigates to the related Game Hub project view. ++- If existing project handoff, route, or mock adapter wiring is missing, add the smallest fix needed. ++- Do not expand into Game Journey unless required as a stub/reference for the handoff. + -+ import { -+ TTS_MESSAGE_STATUSES, -++ TTS_PROFILE_CONTRACT_VERSION, -+ TTS_PROVIDER_ADAPTER_PLAN, -++ createDefaultTextToSpeechProfiles, -+ createEmotionProfile, -++ createMessageStudioTtsProfileOptions, -+ createSpeechPreviewRequest, -++ createTextToSpeechProfile, -++ createTextToSpeechProfileEmotion, -+ createTtsMessage, -+ createVoiceProfile, -+ previewTtsMessage, -+@@ -59,3 +64,46 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai -+ assert.equal(TTS_PROVIDER_ADAPTER_PLAN[0].status, "implemented"); -+ assert.ok(TTS_PROVIDER_ADAPTER_PLAN.slice(1).every((provider) => provider.status === "planned")); - }); -- --@@ -134,42 +134,24 @@ async function closeMessagesRun(failures, page) { -- } -- } -- ---async function addEmotionProfile(page, name) { --- await page.locator("[data-messages-emotion-add-row]").click(); --- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-name]").fill(name); --- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-volume]").fill("0.9"); --- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-pitch]").fill("0.85"); --- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-rate]").fill("0.95"); --- await page.locator("[data-messages-emotion-commit='__new__']").click(); ---} --- ---async function addTtsProfile(page, name) { --- await page.locator("[data-messages-tts-add-row]").click(); --- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-name]").fill(name); --- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-provider]").fill("browser-speech"); --- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-voice]").fill("Test Voice"); --- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-language]").fill("en-US"); --- await page.locator("[data-messages-tts-commit='__new__']").click(); ---} --- ---async function addMessageRow(page, values) { --- await page.locator("[data-messages-add-row]").click(); --+async function addMessage(page, values) { --+ await page.getByRole("button", { name: "Add Message" }).click(); -- await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill(values.name); --- await page.locator("[data-messages-row-editor='__new__'] [data-message-emotion]").selectOption({ label: values.emotion }); --+ await page.locator("[data-messages-row-editor-details='__new__'] [data-message-emotion]").selectOption({ label: values.emotion }); -- await page.locator("[data-messages-row-editor-details='__new__'] [data-message-text]").fill(values.text); -- await page.locator("[data-messages-row-editor-details='__new__'] [data-message-notes]").fill(values.notes || ""); -- await page.locator("[data-messages-commit='__new__']").click(); -- } -- ---async function addSegmentRow(page, values) { --- await page.locator("[data-messages-segment-add-row]").click(); --+async function addPart(page, values) { --+ await page.getByRole("button", { name: "Add Part" }).click(); -- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill(String(values.order)); --- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: values.emotion }); -- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill(values.text); --+ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: values.emotion }); -- await page.locator("[data-messages-segment-commit='__new__']").click(); -- } -- ---test("Message Studio uses table governance, validates rows, and persists through the Local API", async ({ page }) => { --+test("Message Studio renders Messages with child Message Parts and plays ordered parts", async ({ page }) => { -- const sqlitePath = messagesDbPath(); -- await fs.rm(sqlitePath, { force: true }); -- const failures = await openMessagesPage(page, sqlitePath); --@@ -179,242 +161,155 @@ test("Message Studio uses table governance, validates rows, and persists through -- await expect(page.locator(".tool-workspace")).toBeVisible(); -- await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); -- await expect(page.locator("[data-messages-category-name]")).toHaveCount(0); --- await expect(page.locator("[data-messages-category]")).toHaveCount(0); --- await expect(page.getByRole("button", { name: /delete/i })).toHaveCount(0); --- await expect(page.locator("[data-messages-persistence-engine]")).toHaveText("Postgres target"); --- await expect(page.locator("[data-messages-tts-service]")).toHaveValue("browser-speech-synthesis"); --- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Select a message row or segment row before testing speech."); --- await expect(page.locator("[data-messages-test-speech]")).toBeDisabled(); --- await expect(page.locator("[data-messages-preview-message], [data-messages-preview-segments], [data-messages-preview-stop]")).toHaveCount(0); --- --- await expect(page.locator("[data-messages-emotions]")).toContainText("Calm"); --- await expect(page.locator("[data-messages-emotions]")).toContainText("Urgent"); --+ await expect(page.locator("[data-messages-tts-add-row]")).toHaveCount(0); --+ await expect(page.getByRole("columnheader", { name: "Message Name" })).toBeVisible(); --+ await expect(page.getByRole("columnheader", { name: "Default TTS Profile" })).toBeVisible(); -- await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Browser Speech Default"); --+ await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Owned by TTS Studio"); -- --- await addEmotionProfile(page, "Robot"); --- await expect(page.locator("[data-messages-log]")).toHaveText("Updated emotion profile Robot."); --- await expect(page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" })).toContainText("0.9"); --- await page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" }).getByRole("button", { name: "Edit" }).click(); --- await page.locator("[data-messages-emotion-editor] [data-emotion-rate]").fill("1.05"); --- await page.locator("[data-messages-emotion-editor] [data-messages-emotion-commit]").click(); --- await page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" }).getByRole("button", { name: "Disable" }).click(); --- await expect(page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" })).toContainText("Inactive"); --- --- await addTtsProfile(page, "Arcade Browser Voice"); --- await expect(page.locator("[data-messages-log]")).toHaveText("Updated TTS profile Arcade Browser Voice."); --- await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Arcade Browser Voice"); --- await expect(page.locator("[data-messages-tts-count]")).toHaveText("3"); --- await page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" }).getByRole("button", { name: "Edit" }).click(); --- await page.locator("[data-messages-tts-editor] [data-tts-language]").fill("en-GB"); --- await page.locator("[data-messages-tts-editor] [data-messages-tts-commit]").click(); --- await expect(page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" })).toContainText("Active"); --- --- const ttsProfilesResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/tts-profiles`); --- expect(ttsProfilesResult.response.ok).toBe(true); --- expect(ttsProfilesResult.payload.ok).toBe(true); --- const createdTtsProfile = ttsProfilesResult.payload.data.ttsProfiles.find((profile) => profile.name === "Arcade Browser Voice"); --- expect(createdTtsProfile).toEqual(expect.objectContaining({ --- active: true, --- language: "en-GB", --- providerKey: "browser-speech", --- voiceName: "Test Voice", --- })); --- expect(createdTtsProfile.key).toMatch(ULID_PATTERN); --- expect(createdTtsProfile.createdBy).toMatch(ULID_PATTERN); --- expect(createdTtsProfile.updatedBy).toMatch(ULID_PATTERN); --- --- await page.locator("[data-messages-add-row]").click(); --- await expect(page.locator("[data-messages-row-editor='__new__']")).toBeVisible(); --+ await page.getByRole("button", { name: "Add Message" }).click(); -- await page.locator("[data-messages-commit='__new__']").click(); -- await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); -- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Name is required."); -- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion Profile is required."); -- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Text is required."); --- -- await page.locator("[data-messages-cancel='__new__']").click(); --- await addMessageRow(page, { --+ --+ await addMessage(page, { -- emotion: "Urgent", --- name: "Forest Warning", --- notes: "Opening forest danger line.", --- text: "The forest gets darker beyond this point.\nWe are being attacked by bats.", --+ name: "Bat Encounter", --+ notes: "Opening combat line.", --+ text: "Bats drop from the rafters.", -- }); --- await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Forest Warning."); --- await expect(page.locator("[data-messages-count]")).toHaveText("1"); --- await expect(page.locator("[data-messages-table]")).toContainText("Forest Warning"); --- await expect(page.locator("[data-messages-selected-text]")).toHaveText("The forest gets darker beyond this point.\nWe are being attacked by bats."); --+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Bat Encounter."); --+ await expect(page.locator("[data-messages-table]")).toContainText("Bat Encounter"); --+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("Dialog"); --+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("0"); --+ await expect(page.locator("[data-message-default-tts-profile]").first()).toHaveValue(/.+/); --+ --+ const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }); --+ await messageRow.click(); -- await expect(page.locator("[data-messages-segment-host]")).toBeVisible(); --+ await expect(page.getByRole("heading", { name: "Message Parts" })).toBeVisible(); --+ await expect(page.getByRole("columnheader", { name: "Order" })).toBeVisible(); --+ await expect(page.getByRole("columnheader", { name: "Text" })).toBeVisible(); --+ await expect(page.getByRole("columnheader", { exact: true, name: "TTS Profile" })).toBeVisible(); -- --- const listResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages`); --- expect(listResult.response.ok).toBe(true); --- expect(listResult.payload.ok).toBe(true); --- const createdMessage = listResult.payload.data.messages.find((message) => message.name === "Forest Warning"); --- expect(createdMessage).toEqual(expect.objectContaining({ --- active: true, --- emotionProfileName: "Urgent", --- messageText: "The forest gets darker beyond this point.\nWe are being attacked by bats.", --- notes: "Opening forest danger line.", --- })); --- expect(createdMessage.key).toMatch(ULID_PATTERN); --- expect(createdMessage.createdBy).toMatch(ULID_PATTERN); --- expect(createdMessage.updatedBy).toMatch(ULID_PATTERN); --- --- await page.locator("[data-messages-segment-add-row]").click(); --+ await page.getByRole("button", { name: "Add Part" }).click(); -- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill(""); -- await page.locator("[data-messages-segment-commit='__new__']").click(); --- await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); --- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Segment Text is required."); --+ await expect(page.locator("[data-messages-validation-errors]")).toContainText("Part Text is required."); -- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion Profile is required."); -- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Display Order is required."); -- await page.locator("[data-messages-segment-cancel='__new__']").click(); -- --- await addSegmentRow(page, { --+ await addPart(page, { -- emotion: "Calm", -- order: 1, --- text: "The forest gets darker beyond this point.", --+ text: "Bats drop from the rafters.", -- }); --- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 1."); --- await addSegmentRow(page, { --+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 1."); --+ await addPart(page, { -- emotion: "Urgent", -- order: 2, --- text: "We are being attacked by bats.", --+ text: "Keep your torch high.", -- }); --- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 2."); --+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2."); -- await expect(page.locator("[data-messages-segment-row]")).toHaveCount(2); --+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("2"); -- --+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play Message" }).click(); --+ await expect(page.locator("[data-messages-log]")).toHaveText("Play Message queued 2 parts for Bat Encounter."); -- let speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); --- expect(speechCalls).toEqual([]); --- --- await page.locator("[data-messages-preview-tts-profile]").selectOption({ label: "Arcade Browser Voice" }); --- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning" }).click(); --- await expect(page.locator("[data-messages-speech-test-target]")).toHaveText("Message Row: Forest Warning"); --- await expect(page.locator("[data-messages-test-speech]")).toBeEnabled(); --- await page.locator("[data-messages-test-speech]").click(); --- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Speech test started for Message Row: Forest Warning using Browser Speech Synthesis."); --- speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); --+ expect(speechCalls.slice(-2).map((call) => call.text)).toEqual([ --+ "Bats drop from the rafters.", --+ "Keep your torch high.", --+ ]); -- expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ --- lang: "en-GB", --- pitch: 1.08, --- rate: 1.15, --- text: "The forest gets darker beyond this point.\nWe are being attacked by bats.", --+ lang: "en-US", -- type: "speak", -- voiceName: "Test Voice", --- volume: 1, -- })); -- --- await page.locator("[data-messages-segment-row]").filter({ hasText: "The forest gets darker beyond this point." }).click(); --- await expect(page.locator("[data-messages-speech-test-target]")).toHaveText("Segment 1"); --- await page.locator("[data-messages-test-speech]").click(); --- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Speech test started for Segment 1 using Browser Speech Synthesis."); --+ await page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }).getByRole("button", { name: "Play Part" }).click(); --+ await expect(page.locator("[data-messages-log]")).toHaveText("Play Part queued Part 2 using Browser Speech Default."); -- speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); -- expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ --- lang: "en-GB", --- pitch: 1, --- rate: 1, --- text: "The forest gets darker beyond this point.", --+ text: "Keep your torch high.", -- type: "speak", -- voiceName: "Test Voice", --- volume: 1, -- })); -- --+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Edit Message" }).click(); --+ await page.locator("[data-messages-row-editor] [data-message-name]").fill("Bat Encounter Updated"); --+ await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); --+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Bat Encounter Updated."); --+ --+ await page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }).getByRole("button", { name: "Edit Part" }).click(); --+ await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("Keep your torch high and shield raised."); --+ await page.locator("[data-messages-segment-editor] [data-messages-segment-commit]").click(); --+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2."); --+ --+ const listResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages`); --+ expect(listResult.response.ok).toBe(true); --+ expect(listResult.payload.ok).toBe(true); --+ const createdMessage = listResult.payload.data.messages.find((message) => message.name === "Bat Encounter Updated"); --+ expect(createdMessage).toEqual(expect.objectContaining({ --+ active: true, --+ categoryName: "Dialog", --+ emotionProfileName: "Urgent", --+ messageText: "Bats drop from the rafters.", --+ notes: "Opening combat line.", --+ })); --+ expect(createdMessage.key).toMatch(ULID_PATTERN); --+ -- const segmentsResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/segments`); -- expect(segmentsResult.response.ok).toBe(true); --- expect(segmentsResult.payload.ok).toBe(true); -- const createdSegments = segmentsResult.payload.data.segments.filter((segment) => segment.messageKey === createdMessage.key); --- expect(createdSegments).toHaveLength(2); -- expect(createdSegments.map((segment) => segment.displayOrder)).toEqual([1, 2]); --+ expect(createdSegments.map((segment) => segment.segmentText)).toEqual([ --+ "Bats drop from the rafters.", --+ "Keep your torch high and shield raised.", --+ ]); -- createdSegments.forEach((segment) => { -- expect(segment.key).toMatch(ULID_PATTERN); -- expect(segment.createdBy).toMatch(ULID_PATTERN); -- expect(segment.updatedBy).toMatch(ULID_PATTERN); -- }); -- --- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats." }).getByRole("button", { name: "Move Up" }).click(); --- await expect(page.locator("[data-messages-log]")).toHaveText("Segment order updated."); --- await expect(page.locator("[data-messages-segment-row]").first()).toContainText("We are being attacked by bats."); --- --- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats." }).getByRole("button", { name: "Edit" }).click(); --- await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("We are being attacked by bats right now."); --- await page.locator("[data-messages-segment-editor] [data-messages-segment-commit]").click(); --- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 1."); --- await expect(page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." })).toBeVisible(); --- --- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." }).getByRole("button", { name: "Disable" }).click(); --- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled segment row 1."); --- await expect(page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." })).toContainText("Inactive"); --- --- const updatedSegmentsResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/segments`); --- const disabledSegment = updatedSegmentsResult.payload.data.segments.find((segment) => segment.segmentText === "We are being attacked by bats right now."); --- expect(disabledSegment).toEqual(expect.objectContaining({ --- active: false, --- displayOrder: 1, --- emotionProfileName: "Urgent", --- messageKey: createdMessage.key, --- messageName: "Forest Warning", --- segmentText: "We are being attacked by bats right now.", --- })); --- --- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning" }).getByRole("button", { name: "Edit" }).click(); --- await page.locator("[data-messages-row-editor] [data-message-name]").fill("Forest Warning Updated"); --- await page.locator("[data-messages-row-editor-details] [data-message-text]").fill("The forest gets darker beyond this point."); --- await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); --- await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Forest Warning Updated."); --- await expect(page.locator("[data-messages-table]")).toContainText("Forest Warning Updated"); --- --- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning Updated" }).getByRole("button", { name: "Disable" }).click(); --- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled row Forest Warning Updated."); --- await expect(page.locator("[data-messages-row]").filter({ hasText: "Forest Warning Updated" })).toContainText("Inactive"); --- --- const updateResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages/${createdMessage.key}`); --- expect(updateResult.payload.data.message).toEqual(expect.objectContaining({ --- active: false, --- key: createdMessage.key, --- messageText: "The forest gets darker beyond this point.", --- name: "Forest Warning Updated", --- })); --- --- await page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" }).getByRole("button", { name: "Disable" }).click(); --- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled TTS profile Arcade Browser Voice."); --- await expect(page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" })).toContainText("Inactive"); --- --- for (const url of [ --- `${failures.server.baseUrl}/api/messages/messages/${createdMessage.key}`, --- `${failures.server.baseUrl}/api/messages/segments/${disabledSegment.key}`, --- `${failures.server.baseUrl}/api/messages/tts-profiles/${createdTtsProfile.key}`, --- ]) { --- const deleteResult = await fetch(url, { method: "DELETE" }); --- expect(deleteResult.status).toBe(404); --- } --+ expect(failures.failedRequests).toEqual([]); --+ expect(failures.pageErrors).toEqual([]); --+ expect(failures.consoleErrors).toEqual([]); --+ } finally { --+ await closeMessagesRun(failures, page); --+ await fs.rm(sqlitePath, { force: true }); --+ } -++ -++test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { -++ const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; -++ const defaults = createDefaultTextToSpeechProfiles(voiceOptions); -++ const custom = createTextToSpeechProfile({ -++ emotions: [ -++ createTextToSpeechProfileEmotion({ -++ emotion: "urgent", -++ pitch: 1.2, -++ rate: 1.1, -++ ssmlLikePreset: "whisper-ish", -++ volume: 0.8, -++ }), -++ ], -++ id: "custom-profile", -++ name: "Custom Profile", -++ voice: "test-voice", -++ voiceName: "Test Voice", -++ }); -++ const options = createMessageStudioTtsProfileOptions([custom]); -++ -++ assert.equal(TTS_PROFILE_CONTRACT_VERSION, "tts-profile-emotion-v1"); -++ assert.equal(defaults[0].name, "Default Balanced Profile"); -++ assert.equal(defaults[0].messageStudioUsageCount, 1); -++ assert.equal(defaults[0].emotions[0].emotionLabel, "Neutral"); -++ assert.equal(defaults[0].emotions[0].messagePartsUsageCount, 1); -++ assert.deepEqual(options, [{ -++ active: true, -++ emotionSettings: [{ -++ emotion: "urgent", -++ emotionLabel: "Urgent", -++ pitch: 1.2, -++ rate: 1.1, -++ ssmlLikePreset: "whisper-ish", -++ volume: 0.8, -++ }], -++ key: "custom-profile", -++ language: "en-US", -++ name: "Custom Profile", -++ providerKey: "browser-speech", -++ voiceName: "Test Voice", -++ }]); - +}); -- --- await failures.server.close(); --- const restartedServer = await startRepoServer(); --- failures.server = restartedServer; --- const persistedResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/messages/${createdMessage.key}`); --- expect(persistedResult.response.ok).toBe(true); --- expect(persistedResult.payload.data.message).toEqual(expect.objectContaining({ --- active: false, --- key: createdMessage.key, --- name: "Forest Warning Updated", --- messageText: "The forest gets darker beyond this point.", --- })); --+test("Message Studio shows actionable playback error when audio engine is unavailable", async ({ page }) => { --+ const sqlitePath = messagesDbPath(); --+ await fs.rm(sqlitePath, { force: true }); --+ const failures = await openMessagesPage(page, sqlitePath, { speechAvailable: false }); -- --- const persistedSegmentResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/segments/${disabledSegment.key}`); --- expect(persistedSegmentResult.response.ok).toBe(true); --- expect(persistedSegmentResult.payload.data.segment).toEqual(expect.objectContaining({ --- active: false, --- displayOrder: 1, --- key: disabledSegment.key, --- segmentText: "We are being attacked by bats right now.", --- })); --+ try { --+ await addMessage(page, { --+ emotion: "Urgent", --+ name: "Bat Encounter", --+ text: "Bats drop from the rafters.", --+ }); --+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).click(); --+ await addPart(page, { --+ emotion: "Urgent", --+ order: 1, --+ text: "Bats drop from the rafters.", --+ }); -- --- const persistedTtsProfileResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/tts-profiles/${createdTtsProfile.key}`); --- expect(persistedTtsProfileResult.response.ok).toBe(true); --- expect(persistedTtsProfileResult.payload.data.ttsProfile).toEqual(expect.objectContaining({ --- active: false, --- key: createdTtsProfile.key, --- name: "Arcade Browser Voice", --- providerKey: "browser-speech", --- voiceName: "Test Voice", --- })); --+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play Message" }).click(); --+ await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); --+ await expect(page.locator("[data-messages-validation-errors]")).toContainText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); --+ await expect(page.locator("[data-messages-log]")).toHaveText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); --+ await expect(page.locator("[data-messages-preview-status]")).toHaveText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); --+ expect(await page.evaluate(() => window.__messagesSpeechCalls)).toEqual([]); -- -- expect(failures.failedRequests).toEqual([]); -- expect(failures.pageErrors).toEqual([]); --diff --git a/toolbox/messages/index.html b/toolbox/messages/index.html --index 2f1945bbe..5df9a0290 100644 ----- a/toolbox/messages/index.html --+++ b/toolbox/messages/index.html --@@ -39,7 +39,7 @@ --
-- Row Workflow --
---

Review existing rows, select one to update, or add a row from the Message Rows table.

--+

Review messages, open a message to manage parts, or add a message from the Messages table.

--

Disable rows instead of deleting game text.

--
--
--@@ -48,9 +48,9 @@ --
-+diff --git a/toolbox/text-to-speech/index.html b/toolbox/text-to-speech/index.html -+index f5b29b19a..e82dd9468 100644 -+--- a/toolbox/text-to-speech/index.html -++++ b/toolbox/text-to-speech/index.html -+@@ -98,14 +98,50 @@ -+ -+
-
--

Message Studio

---

Message rows define what is said. Emotion profiles and TTS profiles define future delivery settings only.

--+

Messages own game text and ordered parts. Audio playback is delegated to the audio engine.

--
---
0Message Rows
--+
0Messages
--
0Emotion Profiles
--
0TTS Profiles
-+-

Speech Composition

-+-
-++

TTS Studio

-++
-+
0Characters
-++
0TTS Profiles
-++
0Emotion Settings
-+
0Voices
-+
CheckingEngine
-
--@@ -58,17 +58,17 @@ --
--
--
Game Text Repository
---

Message Rows

--+

Messages

--
--
--- --+
-- -- --- --- --- --- --+ --+ -- --+ --+ -- -- -- --@@ -78,7 +78,7 @@ --
NamePrimary EmotionSegmentsTagsMessage NameTypeStatusPartsDefault TTS ProfileActions
--
--
--- --+ --
--
--@@ -153,7 +150,7 @@ --
--

Name: None

--

Emotion Profile: None

---

Segment: None

--+

Part: None

--

Status: None

--

Text:

--
No message selected.
--@@ -184,7 +181,7 @@ --
-- Future Compatibility --
---

Message segments are stored as ordered text with emotion profiles.

--+

Message parts are stored as ordered text with emotion profiles.

--

This tool stores message text exactly as entered.

--
--
--diff --git a/toolbox/messages/message-tts-service-registry.js b/toolbox/messages/message-tts-service-registry.js --index ce914c832..e5cf310ba 100644 ----- a/toolbox/messages/message-tts-service-registry.js --+++ b/toolbox/messages/message-tts-service-registry.js --@@ -31,7 +31,13 @@ function createMessageStudioTtsServiceRegistry({ -- ok: false, -- }; -- } --- return engine.speak(options); --+ const selectedVoice = String(options?.voice || "").trim() --+ || engine.voiceOptions()[0]?.value --+ || ""; --+ return engine.speak({ --+ ...options, --+ voice: selectedVoice, --+ }); -- }, -++
-++
Reusable Speech Profiles
-++

TTS Profiles

-++
-++
-++ -++ -++ -++ -++ -++ -++ -++ -++ -++ -++ -++ -++ -++ -++ -++ -++
Profile NameVoiceLanguageGenderAgeEmotion CountStatusActions
Loading TTS profiles.
-++
-++
-++ -++
-++
-++ -++
-++
-++
-++
Local Preview
-++

Speech Composition

-++
-+ -+ -+
-+diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js -+index 5ab111eeb..9e0af1f87 100644 -+--- a/toolbox/text-to-speech/text2speech.js -++++ b/toolbox/text-to-speech/text2speech.js -+@@ -2,6 +2,7 @@ import { -+ createTextToSpeechQueueItem, -+ filterTextToSpeechVoiceOptions, -+ shapeTextToSpeechOptions, -++ textToSpeechLanguageOptionsFromVoices, -+ textToSpeechPayloadGenderValue, -+ TextToSpeechEngine, -+ uniqueTextToSpeechId, -+@@ -80,6 +81,27 @@ const TTS_PROVIDER_ADAPTER_PLAN = Object.freeze([ -+ } -+ ]); ++## Non-Goals + -++const TTS_PROFILE_CONTRACT_VERSION = "tts-profile-emotion-v1"; -++const NEW_ROW_KEY = "__new__"; -++const DEFAULT_TTS_PROFILE_ID = "default-balanced-profile"; -++const DEFAULT_TTS_EMOTION_ID = "neutral"; -++ -++const TTS_PROFILE_GENDER_OPTIONS = Object.freeze([ -++ Object.freeze({ label: "Neutral", value: "neutral" }), -++ Object.freeze({ label: "Male", value: "male" }), -++ Object.freeze({ label: "Female", value: "female" }), -++ Object.freeze({ label: "Any", value: "any" }) -++]); -++ -++const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ -++ Object.freeze({ label: "Neutral", value: "neutral" }), -++ Object.freeze({ label: "Calm", value: "calm" }), -++ Object.freeze({ label: "Urgent", value: "urgent" }), -++ Object.freeze({ label: "Whisper", value: "whisper" }), -++ Object.freeze({ label: "Angry", value: "angry" }), -++ Object.freeze({ label: "Excited", value: "excited" }) -++]); -++ -+ function boundedNumber(value, { fallback, max, min, value: defaultValue }) { -+ const number = Number(value); -+ const fallbackValue = fallback ?? defaultValue ?? min; -+@@ -139,6 +161,144 @@ function createVoiceProfile({ key = "browser-speech", name = "Browser Speech", p - }; - } --diff --git a/toolbox/messages/messages.js b/toolbox/messages/messages.js --index 7843a8d2c..a2407ba95 100644 ----- a/toolbox/messages/messages.js --+++ b/toolbox/messages/messages.js --@@ -2,7 +2,6 @@ import { -- createMessage, -- createMessageSegment, -- createEmotionProfile, --- createTtsProfile, -- listEmotionProfiles, -- listMessages, -- listMessageSegments, --@@ -10,11 +9,23 @@ import { -- updateEmotionProfile, -- updateMessage, -- updateMessageSegment, --- updateTtsProfile, -- } from "./messages-api-client.js"; -- import { createMessageStudioTtsServiceRegistry } from "./message-tts-service-registry.js"; -- -- const NEW_ROW_KEY = "__new__"; --+const DEFAULT_TTS_PROFILE_KEY = "__default-balanced-tts__"; --+const DEFAULT_TTS_PROFILE = Object.freeze({ --+ active: true, --+ description: "Balanced local browser playback option until authored TTS profiles are available.", --+ key: DEFAULT_TTS_PROFILE_KEY, --+ language: "en-US", --+ name: "Default Balanced TTS", --+ pitch: 1, --+ providerKey: "browser-speech", --+ rate: 1, --+ voiceName: "", --+ volume: 1, --+}); -- const ttsServiceRegistry = createMessageStudioTtsServiceRegistry(); -- -- const elements = { --@@ -37,7 +48,6 @@ const elements = { -- speechTestTarget: document.querySelector("[data-messages-speech-test-target]"), -- table: document.querySelector("[data-messages-table]"), -- testSpeech: document.querySelector("[data-messages-test-speech]"), --- ttsAddRow: document.querySelector("[data-messages-tts-add-row]"), -- ttsCount: document.querySelector("[data-messages-tts-count]"), -- ttsRows: document.querySelector("[data-messages-tts-profiles]"), -- ttsService: document.querySelector("[data-messages-tts-service]"), --@@ -49,9 +59,10 @@ const state = { -- editingEmotionKey: "", -- editingMessageKey: "", -- editingSegmentKey: "", --- editingTtsKey: "", -- emotionProfiles: [], --+ messageTtsProfileKeys: new Map(), -- messages: [], --+ segmentTtsProfileKeys: new Map(), -- segments: [], -- selectedMessageKey: "", -- selectedSegmentKey: "", --@@ -148,6 +159,19 @@ function createSelect(value, dataName, options, placeholder) { -- return select; -- } -- --+function createTtsProfileSelect(value, dataName, identityKey) { --+ const select = createSelect( --+ value || defaultTtsProfileKey(), --+ dataName, --+ activeTtsProfileOptions(), --+ "Select TTS profile", --+ ); --+ if (identityKey) { --+ select.dataset.messagesTtsIdentity = identityKey; --+ } --+ return select; ++- Do not change unrelated Game Hub workflows. ++- Do not introduce real database persistence. ++- Do not add SQLite. ++- Do not change full samples smoke behavior. + -++function slugFromText(value, fallback = "tts-profile") { -++ const slug = String(value || "") -++ .trim() -++ .toLowerCase() -++ .replace(/[^a-z0-9]+/g, "-") -++ .replace(/^-+|-+$/g, ""); -++ return slug || fallback; - +} - + -- function populateSelect(select, options, placeholder) { -- if (!select) { -- return; --@@ -225,12 +249,35 @@ function emotionProfileByKey(profileKey) { -- return state.emotionProfiles.find((profile) => profile.key === profileKey) || null; -- } -- ---function ttsProfileByKey(profileKey) { --- return state.ttsProfiles.find((profile) => profile.key === profileKey) || null; --+function activeTtsProfileOptions() { --+ const activeProfiles = state.ttsProfiles.filter((profile) => profile.active); --+ return activeProfiles.length ? activeProfiles : [DEFAULT_TTS_PROFILE]; -++function labelForOption(options, value, fallback = "") { -++ return options.find((option) => String(option.value) === String(value))?.label || fallback || String(value || ""); - +} - + --+function defaultTtsProfileKey() { --+ return activeTtsProfileOptions()[0]?.key || DEFAULT_TTS_PROFILE_KEY; -++function createTextToSpeechProfileEmotion({ -++ active = true, -++ emotion = "neutral", -++ id = "", -++ messagePartsUsageCount = 0, -++ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, -++ rate = TEXT_TO_SPEECH_DEFAULTS.rate, -++ ssmlLikePreset = TEXT_TO_SPEECH_DEFAULTS.ssmlLikePreset, -++ volume = TEXT_TO_SPEECH_DEFAULTS.volume -++} = {}) { -++ const emotionKey = slugFromText(emotion, DEFAULT_TTS_EMOTION_ID); -++ return { -++ active: active !== false, -++ emotion: emotionKey, -++ emotionLabel: labelForOption(TTS_PROFILE_EMOTION_OPTIONS, emotionKey, "Neutral"), -++ id: id || emotionKey, -++ messagePartsUsageCount: Math.max(0, Number(messagePartsUsageCount) || 0), -++ pitch: boundedNumber(pitch, TEXT_TO_SPEECH_RANGE_DEFAULTS.pitch), -++ rate: boundedNumber(rate, TEXT_TO_SPEECH_RANGE_DEFAULTS.rate), -++ ssmlLikePreset: TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS.some((option) => option.value === ssmlLikePreset) ? ssmlLikePreset : "normal", -++ volume: boundedNumber(volume, TEXT_TO_SPEECH_RANGE_DEFAULTS.volume) -++ }; - +} - + --+function ttsProfileOptionByKey(profileKey) { --+ return activeTtsProfileOptions().find((profile) => profile.key === profileKey) --+ || activeTtsProfileOptions()[0] --+ || DEFAULT_TTS_PROFILE; -++function createTextToSpeechProfile({ -++ active = true, -++ age = TEXT_TO_SPEECH_DEFAULTS.voiceAge, -++ emotions = [], -++ gender = "neutral", -++ id = "", -++ language = TEXT_TO_SPEECH_DEFAULTS.language, -++ messageStudioUsageCount = 0, -++ name = "Default Balanced Profile", -++ voice = "", -++ voiceName = "" -++} = {}) { -++ const profileName = String(name || "Default Balanced Profile").trim() || "Default Balanced Profile"; -++ const emotionRows = Array.isArray(emotions) && emotions.length -++ ? emotions.map((emotion) => createTextToSpeechProfileEmotion(emotion)) -++ : [createTextToSpeechProfileEmotion()]; -++ return { -++ active: active !== false, -++ age: String(age || TEXT_TO_SPEECH_DEFAULTS.voiceAge), -++ emotions: emotionRows, -++ gender: String(gender || "neutral"), -++ id: id || slugFromText(profileName, "tts-profile"), -++ language: String(language || TEXT_TO_SPEECH_DEFAULTS.language), -++ messageStudioUsageCount: Math.max(0, Number(messageStudioUsageCount) || 0), -++ name: profileName, -++ owner: TTS_OWNERSHIP.AUDIO, -++ providerKey: "browser-speech", -++ voice: String(voice || ""), -++ voiceName: String(voiceName || voice || "Default browser voice") -++ }; - +} - + --+function selectedTtsProfileForMessage(messageKey) { --+ return ttsProfileOptionByKey(state.messageTtsProfileKeys.get(messageKey) || defaultTtsProfileKey()); -++function defaultVoiceForProfile(voiceOptions = [], preferredGender = "") { -++ if (!voiceOptions.length) { -++ return null; -++ } -++ const preferred = voiceOptions.find((option) => { -++ const text = `${option.name || ""} ${option.label || ""}`.toLowerCase(); -++ if (preferredGender === "male") return /\bmale\b|\bman\b|\bdavid\b|\bmark\b/.test(text); -++ if (preferredGender === "female") return /\bfemale\b|\bwoman\b|\bzira\b/.test(text); -++ return false; -++ }); -++ return preferred || voiceOptions[0]; - +} - + --+function selectedTtsProfileForSegment(segmentKey, messageKey = state.selectedMessageKey) { --+ return ttsProfileOptionByKey( --+ state.segmentTtsProfileKeys.get(segmentKey) --+ || state.messageTtsProfileKeys.get(messageKey) --+ || defaultTtsProfileKey(), --+ ); -- } -- -- function selectedTtsProfile() { --- return ttsProfileByKey(elements.previewTtsProfile?.value || ""); --+ return ttsProfileOptionByKey(elements.previewTtsProfile?.value || defaultTtsProfileKey()); -- } -- -- function selectedTtsService() { --@@ -243,10 +290,11 @@ function selectedSpeechTarget() { -- return { -- emotionProfile: emotionProfileByKey(segment.emotionProfileKey), -- id: segment.key, --- label: `Segment ${segment.displayOrder}`, --- name: `${segment.messageName || "Message"} segment ${segment.displayOrder}`, --+ label: `Part ${segment.displayOrder}`, --+ name: `${segment.messageName || "Message"} part ${segment.displayOrder}`, --+ profile: selectedTtsProfileForSegment(segment.key, segment.messageKey), -- text: segment.segmentText, --- type: "segment", --+ type: "part", -- }; -- } -- const message = selectedMessage(); --@@ -256,8 +304,9 @@ function selectedSpeechTarget() { -- return { -- emotionProfile: emotionProfileByKey(message.emotionProfileKey), -- id: message.key, --- label: `Message Row: ${message.name}`, --+ label: `Message: ${message.name}`, -- name: message.name, --+ profile: selectedTtsProfileForMessage(message.key), -- text: message.messageText, -- type: "message", -++function createDefaultTextToSpeechProfiles(voiceOptions = []) { -++ const balancedVoice = defaultVoiceForProfile(voiceOptions); -++ const manVoice = defaultVoiceForProfile(voiceOptions, "male") || balancedVoice; -++ const womanVoice = defaultVoiceForProfile(voiceOptions, "female") || voiceOptions[1] || balancedVoice; -++ return [ -++ createTextToSpeechProfile({ -++ emotions: [createTextToSpeechProfileEmotion({ messagePartsUsageCount: 1 })], -++ id: DEFAULT_TTS_PROFILE_ID, -++ language: balancedVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, -++ messageStudioUsageCount: 1, -++ name: "Default Balanced Profile", -++ voice: balancedVoice?.value || "", -++ voiceName: balancedVoice?.name || balancedVoice?.label || "Default browser voice" -++ }), -++ createTextToSpeechProfile({ -++ gender: "male", -++ id: "man-profile-1", -++ language: manVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, -++ name: "Man Profile 1", -++ voice: manVoice?.value || "", -++ voiceName: manVoice?.name || manVoice?.label || "Default browser voice" -++ }), -++ createTextToSpeechProfile({ -++ gender: "female", -++ id: "woman-profile-2", -++ language: womanVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, -++ name: "Woman Profile 2", -++ voice: womanVoice?.value || "", -++ voiceName: womanVoice?.name || womanVoice?.label || "Default browser voice" -++ }) -++ ]; -++} -++ -++function createMessageStudioTtsProfileOptions(profiles = []) { -++ return profiles -++ .filter((profile) => profile?.active !== false) -++ .map((profile) => ({ -++ active: true, -++ emotionSettings: Array.isArray(profile.emotions) -++ ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ -++ emotion: emotion.emotion, -++ emotionLabel: emotion.emotionLabel, -++ pitch: emotion.pitch, -++ rate: emotion.rate, -++ ssmlLikePreset: emotion.ssmlLikePreset, -++ volume: emotion.volume -++ })) -++ : [], -++ key: profile.id, -++ language: profile.language, -++ name: profile.name, -++ providerKey: profile.providerKey || "browser-speech", -++ voiceName: profile.voiceName || profile.voice || "" -++ })); -++} -++ -+ function createSpeechPreviewRequest({ -+ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, -+ rate = TEXT_TO_SPEECH_DEFAULTS.rate, -+@@ -250,6 +410,7 @@ function queueItemMeta(item) { -+ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeechEngine() } = {}) { -+ const elements = { -+ addItem: root.querySelector("[data-tts-add-item]"), -++ addProfile: root.querySelector("[data-tts-add-profile]"), -+ age: root.querySelector("[data-tts-age-select]"), -+ characterPreset: root.querySelector("[data-tts-character-preset-select]"), -+ clearStatus: root.querySelector("[data-tts-clear-status]"), -+@@ -266,6 +427,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech -+ pause: root.querySelector("[data-tts-pause]"), -+ pitch: root.querySelector("[data-tts-pitch]"), -+ pitchValue: root.querySelector("[data-tts-pitch-value]"), -++ profileCount: root.querySelector("[data-tts-profile-count]"), -++ profileEmotionCount: root.querySelector("[data-tts-emotion-count]"), -++ profileTable: root.querySelector("[data-tts-profile-table]"), -+ queueList: root.querySelector("[data-tts-queue-list]"), -+ rate: root.querySelector("[data-tts-rate]"), -+ rateValue: root.querySelector("[data-tts-rate-value]"), -+@@ -285,7 +449,11 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech - }; --@@ -293,7 +342,7 @@ function selectOptionsWithCurrent(currentKey) { -- function renderCounts() { -- setText(elements.count, String(state.messages.length)); -- setText(elements.emotionCount, String(state.emotionProfiles.length)); --- setText(elements.ttsCount, String(state.ttsProfiles.length)); --+ setText(elements.ttsCount, String(activeTtsProfileOptions().length)); -- } -- -- function renderPersistence(persistence = {}) { --@@ -330,7 +379,7 @@ function renderTtsServiceOptions() { -- } -- -- function renderTtsProfileOptions() { --- const activeProfiles = state.ttsProfiles.filter((profile) => profile.active); --+ const activeProfiles = activeTtsProfileOptions(); -- populateSelect(elements.previewTtsProfile, activeProfiles, "Select TTS profile"); -- const selected = selectedTtsProfile(); -- if (!selected && activeProfiles[0]) { --@@ -361,9 +410,6 @@ function speechTestReadiness() { -- if (!profile) { -- return { message: "Select an active TTS profile before testing speech.", ok: false }; -- } --- if (!String(profile.voiceName || "").trim()) { --- return { message: "Select a TTS profile with a voice before testing speech.", ok: false }; --- } -- if (!target.emotionProfile) { -- return { message: "Selected item needs an emotion profile before testing speech.", ok: false }; -- } --@@ -393,22 +439,27 @@ function createMessageEditRows(message = null) { -- const nameCell = document.createElement("td"); -- nameCell.append(createInput(message?.name || "", "messageName")); -- --- const emotionCell = document.createElement("td"); --- emotionCell.append(createSelect(message?.emotionProfileKey || "", "messageEmotion", selectOptionsWithCurrent(message?.emotionProfileKey || ""), "Select emotion profile")); --- --- const segmentCell = createCell(message ? String(messageSegments(message.key).length) : "0"); --- const tagsCell = createCell("Tags planned"); --+ const typeCell = createCell(message?.categoryName || "Dialog"); -- -- const statusCell = document.createElement("td"); -- statusCell.append(createCheckbox(message?.active !== false, "messageActive")); -- --+ const partCell = createCell(message ? String(messageSegments(message.key).length) : "0"); --+ --+ const ttsCell = document.createElement("td"); --+ ttsCell.append(createTtsProfileSelect( --+ message ? selectedTtsProfileForMessage(message.key).key : defaultTtsProfileKey(), --+ "messageDefaultTtsProfile", --+ key, --+ )); --+ -- const actions = document.createElement("td"); -- actions.append(createActionGroup( --- createButton("Update Row", "messagesCommit", key), --+ createButton("Save", "messagesCommit", key), -- createButton("Cancel", "messagesCancel", key), -- )); -- --- row.append(nameCell, emotionCell, segmentCell, tagsCell, statusCell, actions); --+ row.append(nameCell, typeCell, statusCell, partCell, ttsCell, actions); -- -- const detailRow = document.createElement("tr"); -- detailRow.dataset.messagesRowEditorDetails = key; --@@ -417,6 +468,7 @@ function createMessageEditRows(message = null) { -- const stack = document.createElement("div"); -- stack.className = "content-stack"; -- stack.append( --+ createField("Primary Emotion", createSelect(message?.emotionProfileKey || "", "messageEmotion", selectOptionsWithCurrent(message?.emotionProfileKey || ""), "Select emotion profile")), -- createField("Message Text", createTextarea(message?.messageText || "", "messageText", 6)), -- createField("Notes", createTextarea(message?.notes || "", "messageNotes", 3)), -- ); --@@ -432,19 +484,19 @@ function createMessageSegmentTable() { -- -- const context = document.createElement("div"); -- context.className = "kicker"; --- context.textContent = "Message Row / Segment Table"; --+ context.textContent = "Message / Message Parts"; -- const heading = document.createElement("h3"); --- heading.textContent = "Message Segments"; --+ heading.textContent = "Message Parts"; -- wrapper.append(context, heading); -- -- const tableWrapper = document.createElement("div"); -- tableWrapper.className = "table-wrapper"; -- const table = document.createElement("table"); -- table.className = "data-table"; --- table.setAttribute("aria-label", "Selected message segments"); --+ table.setAttribute("aria-label", "Message parts"); -- const thead = document.createElement("thead"); -- const headerRow = document.createElement("tr"); --- ["Order", "Emotion", "Text", "Status", "Actions"].forEach((label) => { --+ ["Order", "Text", "Emotion", "TTS Profile", "Status", "Actions"].forEach((label) => { -- const header = document.createElement("th"); -- header.scope = "col"; -- header.textContent = label; --@@ -456,7 +508,7 @@ function createMessageSegmentTable() { -- -- const segments = selectedMessageSegments(); -- if (!segments.length && state.editingSegmentKey !== NEW_ROW_KEY) { --- tbody.append(tableMessage(5, "No segments saved for this message.")); --+ tbody.append(tableMessage(6, "No message parts saved for this message.")); -- } -- -- segments.forEach((segment, index) => { --@@ -472,15 +524,23 @@ function createMessageSegmentTable() { -- moveUp.disabled = index === 0; -- moveDown.disabled = index === segments.length - 1; -- actions.append(createActionGroup( --- createButton("Edit", "messagesSegmentEdit", segment.key), --+ createButton("Play Part", "messagesSegmentPlay", segment.key), --+ createButton("Edit Part", "messagesSegmentEdit", segment.key), -- moveUp, -- moveDown, -- segment.active ? createButton("Disable", "messagesSegmentDisable", segment.key) : null, -- )); --+ const ttsCell = document.createElement("td"); --+ ttsCell.append(createTtsProfileSelect( --+ selectedTtsProfileForSegment(segment.key, segment.messageKey).key, --+ "segmentTtsProfile", --+ segment.key, --+ )); -- row.append( -- createCell(String(segment.displayOrder)), --- createCell(segment.emotionProfileName || "Unknown"), -- createCell(segment.segmentText), --+ createCell(segment.emotionProfileName || "Unknown"), --+ ttsCell, -- createCell(statusForActive(segment.active)), -- actions, -- ); --@@ -495,7 +555,7 @@ function createMessageSegmentTable() { -- tableWrapper.append(table); -- const actionGroup = document.createElement("div"); -- actionGroup.className = "action-group"; --- actionGroup.append(createButton("Add Segment Row", "messagesSegmentAddRow", state.selectedMessageKey)); --+ actionGroup.append(createButton("Add Part", "messagesSegmentAddRow", state.selectedMessageKey)); -- wrapper.append(tableWrapper, actionGroup); -- return wrapper; -- } --@@ -508,22 +568,29 @@ function createSegmentEditRow(segment = null) { -- const orderCell = document.createElement("td"); -- orderCell.append(createNumberInput(segment?.displayOrder || nextSegmentOrder(), "segmentOrder", { min: 1, step: 1 })); -- --+ const textCell = document.createElement("td"); --+ textCell.append(createTextarea(segment?.segmentText || "", "segmentText", 3)); --+ -- const emotionCell = document.createElement("td"); -- emotionCell.append(createSelect(segment?.emotionProfileKey || "", "segmentEmotion", selectOptionsWithCurrent(segment?.emotionProfileKey || ""), "Select emotion")); -- --- const textCell = document.createElement("td"); --- textCell.append(createTextarea(segment?.segmentText || "", "segmentText", 3)); --+ const ttsCell = document.createElement("td"); --+ ttsCell.append(createTtsProfileSelect( --+ segment ? selectedTtsProfileForSegment(segment.key, segment.messageKey).key : selectedTtsProfileForMessage(state.selectedMessageKey).key, --+ "segmentTtsProfile", --+ key, --+ )); -- -- const statusCell = document.createElement("td"); -- statusCell.append(createCheckbox(segment?.active !== false, "segmentActive")); -- -- const actions = document.createElement("td"); -- actions.append(createActionGroup( --- createButton("Update Row", "messagesSegmentCommit", key), --+ createButton("Save", "messagesSegmentCommit", key), -- createButton("Cancel", "messagesSegmentCancel", key), -- )); -- --- row.append(orderCell, emotionCell, textCell, statusCell, actions); --+ row.append(orderCell, textCell, emotionCell, ttsCell, statusCell, actions); -- return row; -- } -- --@@ -546,7 +613,7 @@ function renderMessageRows() { -- } -- elements.table.replaceChildren(); -- if (!state.messages.length && state.editingMessageKey !== NEW_ROW_KEY) { --- elements.table.append(tableMessage(6, "No message rows saved yet.")); --+ elements.table.append(tableMessage(6, "No messages saved yet. Add a message such as Bat Encounter.")); -- return; -+ const state = { -+ applyingItem: false, -++ editingEmotionId: "", -++ editingProfileId: "", -++ profiles: [], -+ queue: [], -++ selectedProfileId: "", -+ selectedItemId: "", -+ sliderOverrides: { pitch: false, rate: false, volume: false }, -+ voiceOptions: [] -+@@ -330,6 +498,477 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech -+ }; - } -- --@@ -559,17 +626,28 @@ function renderMessageRows() { -- -- const row = document.createElement("tr"); -- row.dataset.messagesRow = message.key; ++## Validation + -++ function selectedProfile() { -++ return state.profiles.find((profile) => profile.id === state.selectedProfileId) || null; -++ } -++ -++ function profileInUseByMessageStudio(profile) { -++ return Number(profile?.messageStudioUsageCount || 0) > 0; -++ } -++ -++ function emotionInUseByMessageParts(emotion) { -++ return Number(emotion?.messagePartsUsageCount || 0) > 0; -++ } -++ -++ function createCell(text) { -++ const cell = document.createElement("td"); -++ cell.textContent = text; -++ return cell; -++ } -++ -++ function createButton(label, dataName, value) { -++ const button = document.createElement("button"); -++ button.className = "btn btn--compact"; -++ button.type = "button"; -++ button.dataset[dataName] = value; -++ button.textContent = label; -++ return button; -++ } -++ -++ function createActionGroup(...buttons) { -++ const group = document.createElement("div"); -++ group.className = "action-group action-group--tight"; -++ buttons.filter(Boolean).forEach((button) => group.append(button)); -++ return group; -++ } -++ -++ function tableMessage(colSpan, text) { -++ const row = document.createElement("tr"); -++ const cell = document.createElement("td"); -++ cell.colSpan = colSpan; -++ cell.textContent = text; -++ row.append(cell); -++ return row; -++ } -++ -++ function createTextInput(value, dataName) { -++ const input = document.createElement("input"); -++ input.dataset[dataName] = ""; -++ input.type = "text"; -++ input.value = value || ""; -++ return input; -++ } -++ -++ function createNumberInput(value, dataName, kind) { -++ const input = document.createElement("input"); -++ const range = TEXT_TO_SPEECH_RANGE_DEFAULTS[kind] || TEXT_TO_SPEECH_RANGE_DEFAULTS.rate; -++ input.dataset[dataName] = ""; -++ input.type = "number"; -++ input.min = String(range.min); -++ input.max = String(range.max); -++ input.step = String(range.step); -++ input.value = formatRangeValue(value ?? range.value, kind); -++ return input; -++ } -++ -++ function createCheckbox(checked, dataName) { -++ const label = document.createElement("label"); -++ const input = document.createElement("input"); -++ input.dataset[dataName] = ""; -++ input.type = "checkbox"; -++ input.checked = checked !== false; -++ label.append(input, " Active"); -++ return label; -++ } -++ -++ function createEditorSelect(value, dataName, options, placeholder = "") { -++ const select = document.createElement("select"); -++ select.dataset[dataName] = ""; -++ if (placeholder) { -++ const placeholderOption = document.createElement("option"); -++ placeholderOption.value = ""; -++ placeholderOption.textContent = placeholder; -++ select.append(placeholderOption); -++ } -++ options.forEach((optionValue) => { -++ const option = document.createElement("option"); -++ option.value = String(optionValue.value); -++ option.textContent = optionValue.label; -++ select.append(option); -++ }); -++ select.value = options.some((optionValue) => String(optionValue.value) === String(value)) ? String(value) : String(options[0]?.value || ""); -++ return select; -++ } -++ -++ function voiceSelectOptions() { -++ return state.voiceOptions.length -++ ? state.voiceOptions.map((option) => ({ label: option.label, value: option.value })) -++ : [{ label: "No browser voices available", value: "" }]; -++ } -++ -++ function languageSelectOptions() { -++ const voiceLanguages = textToSpeechLanguageOptionsFromVoices(state.voiceOptions); -++ return voiceLanguages.length ? voiceLanguages : TEXT_TO_SPEECH_LANGUAGE_OPTIONS; -++ } -++ -++ function profileVoiceName(profile) { -++ const match = state.voiceOptions.find((option) => option.value === profile.voice); -++ return match?.name || match?.label || profile.voiceName || "No voice selected"; -++ } -++ -++ function renderProfileCounts() { -++ if (elements.profileCount) elements.profileCount.textContent = String(state.profiles.length); -++ if (elements.profileEmotionCount) { -++ const emotionCount = state.profiles.reduce((total, profile) => total + profile.emotions.length, 0); -++ elements.profileEmotionCount.textContent = String(emotionCount); -++ } -++ } -++ -++ function renderProfileRows() { -++ if (!elements.profileTable) return; -++ elements.profileTable.replaceChildren(); -++ -++ state.profiles.forEach((profile) => { -++ if (state.editingProfileId === profile.id) { -++ elements.profileTable.append(createProfileEditRow(profile)); -++ appendEmotionHost(profile.id); -++ return; -++ } -++ -++ const row = document.createElement("tr"); -++ row.dataset.ttsProfileRow = profile.id; -++ const nameCell = document.createElement("td"); -++ nameCell.dataset.ttsProfileNameCell = profile.id; -++ nameCell.textContent = `${state.selectedProfileId === profile.id ? "v" : ">"} ${profile.name}`; -++ const deleteButton = createButton("Delete", "ttsDeleteProfile", profile.id); -++ if (profileInUseByMessageStudio(profile)) { -++ deleteButton.disabled = true; -++ deleteButton.title = "Delete disabled: profile is in use by Message Studio data."; -++ } -++ const actions = createActionGroup( -++ createButton("Edit Profile", "ttsEditProfile", profile.id), -++ deleteButton, -++ ); -++ row.append( -++ nameCell, -++ createCell(profileVoiceName(profile)), -++ createCell(profile.language), -++ createCell(labelForOption(TTS_PROFILE_GENDER_OPTIONS, profile.gender, "Neutral")), -++ createCell(labelForOption(TEXT_TO_SPEECH_AGE_FILTER_OPTIONS, profile.age, "Any")), -++ createCell(String(profile.emotions.length)), -++ createCell(profile.active ? "Active" : "Inactive"), -++ (() => { -++ const cell = document.createElement("td"); -++ cell.append(actions); -++ return cell; -++ })(), -++ ); -++ elements.profileTable.append(row); -++ appendEmotionHost(profile.id); -++ }); -++ -++ if (state.editingProfileId === NEW_ROW_KEY) { -++ elements.profileTable.append(createProfileEditRow(null)); -++ } -++ -++ if (!state.profiles.length && state.editingProfileId !== NEW_ROW_KEY) { -++ elements.profileTable.append(tableMessage(8, "No TTS profiles yet.")); -++ } -++ renderProfileCounts(); -++ } -++ -++ function createProfileEditRow(profile = null) { -++ const key = profile?.id || NEW_ROW_KEY; -++ const row = document.createElement("tr"); -++ row.dataset.ttsProfileEditor = key; -++ - + const nameCell = document.createElement("td"); --+ const isExpanded = state.selectedMessageKey === message.key; --+ nameCell.dataset.messagesNameCell = message.key; --+ nameCell.textContent = `${isExpanded ? "v" : ">"} ${message.name}`; --+ const ttsCell = document.createElement("td"); --+ ttsCell.append(createTtsProfileSelect( --+ selectedTtsProfileForMessage(message.key).key, --+ "messageDefaultTtsProfile", --+ message.key, -++ nameCell.append(createTextInput(profile?.name || "", "ttsProfileName")); -++ const voiceCell = document.createElement("td"); -++ voiceCell.append(createEditorSelect(profile?.voice || "", "ttsProfileVoice", voiceSelectOptions(), "Select voice")); -++ const languageCell = document.createElement("td"); -++ languageCell.append(createEditorSelect(profile?.language || TEXT_TO_SPEECH_DEFAULTS.language, "ttsProfileLanguage", languageSelectOptions())); -++ const genderCell = document.createElement("td"); -++ genderCell.append(createEditorSelect(profile?.gender || "neutral", "ttsProfileGender", TTS_PROFILE_GENDER_OPTIONS)); -++ const ageCell = document.createElement("td"); -++ ageCell.append(createEditorSelect(profile?.age || TEXT_TO_SPEECH_DEFAULTS.voiceAge, "ttsProfileAge", TEXT_TO_SPEECH_AGE_FILTER_OPTIONS)); -++ const emotionCountCell = createCell(profile ? String(profile.emotions.length) : "1"); -++ const statusCell = document.createElement("td"); -++ statusCell.append(createCheckbox(profile?.active !== false, "ttsProfileActive")); -++ const actionsCell = document.createElement("td"); -++ actionsCell.append(createActionGroup( -++ createButton("Save", "ttsCommitProfile", key), -++ createButton("Cancel", "ttsCancelProfile", key), - + )); -- const actions = document.createElement("td"); -- actions.append(createActionGroup( --- createButton("Edit", "messagesEdit", message.key), --+ createButton("Play Message", "messagesPlay", message.key), --+ createButton("Edit Message", "messagesEdit", message.key), -- message.active ? createButton("Disable", "messagesDisable", message.key) : null, -- )); -- row.append( --- createCell(message.name), --- createCell(message.emotionProfileName || "Unknown"), --- createCell(String(messageSegments(message.key).length)), --- createCell("Tags planned"), --+ nameCell, --+ createCell(message.categoryName || "Dialog"), -- createCell(statusForActive(message.active)), --+ createCell(String(messageSegments(message.key).length)), --+ ttsCell, -- actions, -- ); -- elements.table.append(row); --@@ -640,63 +718,24 @@ function renderEmotionRows() { -- } -- } -- ---function createTtsEditRow(profile = null) { --- const key = profile?.key || NEW_ROW_KEY; --- const row = document.createElement("tr"); --- row.dataset.messagesTtsEditor = key; --- --- const nameCell = document.createElement("td"); --- nameCell.append(createInput(profile?.name || "", "ttsName")); --- const providerCell = document.createElement("td"); --- providerCell.append(createInput(profile?.providerKey || "browser-speech", "ttsProvider")); --- const voiceCell = document.createElement("td"); --- voiceCell.append(createInput(profile?.voiceName || "", "ttsVoice")); --- const languageCell = document.createElement("td"); --- languageCell.append(createInput(profile?.language || "en-US", "ttsLanguage")); --- const statusCell = document.createElement("td"); --- statusCell.append(createCheckbox(profile?.active !== false, "ttsActive")); --- const actions = document.createElement("td"); --- actions.append(createActionGroup( --- createButton("Update Row", "messagesTtsCommit", key), --- createButton("Cancel", "messagesTtsCancel", key), --- )); --- row.append(nameCell, providerCell, voiceCell, languageCell, statusCell, actions); --- return row; ---} --- -- function renderTtsRows() { -- if (!elements.ttsRows) { -- return; -- } -- elements.ttsRows.replaceChildren(); --- state.ttsProfiles.forEach((profile) => { --- if (state.editingTtsKey === profile.key) { --- elements.ttsRows.append(createTtsEditRow(profile)); --- return; --- } --+ activeTtsProfileOptions().forEach((profile) => { -- const row = document.createElement("tr"); -- row.dataset.messagesTtsRow = profile.key; --- const actions = document.createElement("td"); --- actions.append(createActionGroup( --- createButton("Edit", "messagesTtsEdit", profile.key), --- profile.active ? createButton("Disable", "messagesTtsDisable", profile.key) : null, --- )); -- row.append( -- createCell(profile.name), -- createCell(profile.providerKey), -- createCell(profile.voiceName || ""), -- createCell(profile.language), -- createCell(statusForActive(profile.active)), --- actions, --+ createCell(profile.key === DEFAULT_TTS_PROFILE_KEY ? "Default option" : "Owned by TTS Studio"), -- ); -- elements.ttsRows.append(row); -- }); --- if (state.editingTtsKey === NEW_ROW_KEY) { --- elements.ttsRows.append(createTtsEditRow(null)); --- } --- if (!state.ttsProfiles.length && state.editingTtsKey !== NEW_ROW_KEY) { --- elements.ttsRows.append(tableMessage(6, "No TTS profiles saved yet.")); --- } -- } -- -- function render(persistence = {}) { --@@ -722,7 +761,7 @@ function messageValues(key) { -- const details = elements.table?.querySelector(`[data-messages-row-editor-details="${key}"]`); -- return { -- active: editorChecked(root, "[data-message-active]"), --- emotionProfileKey: editorValue(root, "[data-message-emotion]"), --+ emotionProfileKey: editorValue(details, "[data-message-emotion]"), -- messageText: editorValue(details, "[data-message-text]"), -- name: editorValue(root, "[data-message-name]"), -- notes: editorValue(details, "[data-message-notes]"), --@@ -757,10 +796,10 @@ function segmentValues(key) { -- function validateSegment(values) { -- const errors = []; -- if (!state.selectedMessageKey) { --- errors.push("Select a message row before adding segments."); --+ errors.push("Select a message before adding parts."); -- } -- if (!values.segmentText.trim()) { --- errors.push("Segment Text is required."); --+ errors.push("Part Text is required."); -- } -- if (!values.emotionProfileKey) { -- errors.push("Emotion Profile is required."); --@@ -795,36 +834,6 @@ function validateEmotion(values) { -- return values.name.trim() ? [] : ["Emotion Profile Name is required."]; -- } -- ---function ttsValues(key) { --- const root = elements.ttsRows?.querySelector(`[data-messages-tts-editor="${key}"]`); --- const existing = key === NEW_ROW_KEY ? null : ttsProfileByKey(key); --- return { --- active: editorChecked(root, "[data-tts-active]"), --- description: existing?.description || "", --- language: editorValue(root, "[data-tts-language]"), --- name: editorValue(root, "[data-tts-name]"), --- pitch: existing?.pitch ?? 1, --- providerKey: editorValue(root, "[data-tts-provider]"), --- rate: existing?.rate ?? 1, --- voiceName: editorValue(root, "[data-tts-voice]"), --- volume: existing?.volume ?? 1, --- }; ---} --- ---function validateTts(values) { --- const errors = []; --- if (!values.name.trim()) { --- errors.push("TTS Profile Name is required."); --- } --- if (!values.providerKey.trim()) { --- errors.push("Provider is required."); --- } --- if (!values.language.trim()) { --- errors.push("Language is required."); --- } --- return errors; ---} --- -- async function loadAll() { -- const emotionPayload = listEmotionProfiles(); -- const ttsPayload = listTtsProfiles(); --@@ -893,10 +902,10 @@ async function commitSegment(key) { -- state.editingSegmentKey = ""; -- state.selectedSegmentKey = result.segment.key; -- await reloadAfterChange(state.selectedMessageKey, result.segment.key); --- setText(elements.log, `Updated segment row ${result.segment.displayOrder}.`); --+ setText(elements.log, `Updated message part ${result.segment.displayOrder}.`); -- } catch (error) { -- showValidation([error instanceof Error ? error.message : String(error || "Segment row update failed.")]); --- setText(elements.log, "Segment row update failed."); --+ setText(elements.log, "Message part update failed."); -- } -- } -- --@@ -922,28 +931,6 @@ async function commitEmotion(key) { -- } -- } -- ---async function commitTts(key) { --- const values = ttsValues(key); --- const errors = validateTts(values); --- if (errors.length) { --- showValidation(errors); --- setText(elements.log, "TTS profile update blocked by validation."); --- return; --- } --- clearValidation(); --- try { --- const result = key === NEW_ROW_KEY --- ? createTtsProfile(values) --- : updateTtsProfile(key, values); --- state.editingTtsKey = ""; --- await reloadAfterChange(); --- setText(elements.log, `Updated TTS profile ${result.ttsProfile.name}.`); --- } catch (error) { --- showValidation([error instanceof Error ? error.message : String(error || "TTS profile update failed.")]); --- setText(elements.log, "TTS profile update failed."); --- } ---} --- -- async function disableMessage(key) { -- const message = state.messages.find((candidate) => candidate.key === key); -- if (!message) { --@@ -981,10 +968,10 @@ async function disableSegment(key) { -- }); -- state.selectedSegmentKey = result.segment.key; -- await reloadAfterChange(segment.messageKey, result.segment.key); --- setText(elements.log, `Disabled segment row ${result.segment.displayOrder}.`); --+ setText(elements.log, `Disabled message part ${result.segment.displayOrder}.`); -- } catch (error) { -- showValidation([error instanceof Error ? error.message : String(error || "Segment status update failed.")]); --- setText(elements.log, "Segment status update failed."); --+ setText(elements.log, "Message part status update failed."); -- } -- } -- --@@ -996,25 +983,110 @@ function testSelectedSpeech() { -- return; -- } -- const service = selectedTtsService(); --- const profile = selectedTtsProfile(); -- const target = selectedSpeechTarget(); --- const emotion = target.emotionProfile; --- const result = ttsServiceRegistry.speak(service.key, { --+ const result = speakTarget(service, target, target.profile || selectedTtsProfile()); --+ if (!result.ok) { --+ setText(elements.previewStatus, result.message || "Speech test failed."); --+ setText(elements.log, result.message || "Speech test failed."); --+ return; -++ -++ row.append(nameCell, voiceCell, languageCell, genderCell, ageCell, emotionCountCell, statusCell, actionsCell); -++ return row; - + } --+ const message = `Speech test started for ${target.label} using ${service.name}.`; --+ setText(elements.previewStatus, message); --+ setText(elements.log, message); --+} - + --+function visiblePlaybackError(message) { --+ const safeMessage = message || "Message Studio playback failed. Check the selected message, part, and TTS profile."; --+ showValidation([safeMessage]); --+ setText(elements.previewStatus, safeMessage); --+ setText(elements.log, safeMessage); --+ return { message: safeMessage, ok: false }; --+} -++ function appendEmotionHost(profileId) { -++ if (state.selectedProfileId !== profileId) return; -++ const hostRow = document.createElement("tr"); -++ hostRow.dataset.ttsEmotionHost = profileId; -++ const cell = document.createElement("td"); -++ cell.colSpan = 8; -++ cell.append(createEmotionTable(profileId)); -++ hostRow.append(cell); -++ elements.profileTable.append(hostRow); -++ } - + --+function playbackService() { --+ return selectedTtsService() || state.ttsServices.find((service) => service.available) || null; --+} -++ function createEmotionTable(profileId) { -++ const profile = state.profiles.find((candidate) => candidate.id === profileId); -++ const wrapper = document.createElement("div"); -++ wrapper.className = "content-stack"; -++ const context = document.createElement("div"); -++ context.className = "kicker"; -++ context.textContent = "TTS Profile / Emotion Settings"; -++ const heading = document.createElement("h3"); -++ heading.textContent = "Emotion Settings"; -++ wrapper.append(context, heading); - + --+function speakTarget(service, target, profile) { --+ if (!service) { --+ return visiblePlaybackError("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); --+ } --+ if (!service.available) { --+ return visiblePlaybackError(service.unavailableMessage || "Audio engine is unavailable for Message Studio playback."); -++ const tableWrapper = document.createElement("div"); -++ tableWrapper.className = "table-wrapper"; -++ const table = document.createElement("table"); -++ table.className = "data-table"; -++ table.setAttribute("aria-label", "Emotion Settings"); -++ const thead = document.createElement("thead"); -++ const headerRow = document.createElement("tr"); -++ ["Emotion", "Pitch", "Rate", "Volume", "SSML-like Preset", "Status", "Actions"].forEach((label) => { -++ const header = document.createElement("th"); -++ header.scope = "col"; -++ header.textContent = label; -++ headerRow.append(header); -++ }); -++ thead.append(headerRow); -++ const tbody = document.createElement("tbody"); -++ tbody.dataset.ttsEmotionTable = profileId; -++ -++ if (!profile?.emotions.length && state.editingEmotionId !== NEW_ROW_KEY) { -++ tbody.append(tableMessage(7, "No emotion settings for this profile.")); -++ } -++ -++ profile?.emotions.forEach((emotion) => { -++ if (state.editingEmotionId === emotion.id) { -++ tbody.append(createEmotionEditRow(emotion)); -++ return; -++ } -++ const row = document.createElement("tr"); -++ row.dataset.ttsEmotionRow = emotion.id; -++ const deleteButton = createButton("Delete", "ttsDeleteEmotion", emotion.id); -++ if (emotionInUseByMessageParts(emotion)) { -++ deleteButton.disabled = true; -++ deleteButton.title = "Delete disabled: emotion is in use by Message Parts."; -++ } -++ const actions = createActionGroup( -++ createButton("Edit Emotion", "ttsEditEmotion", emotion.id), -++ deleteButton, -++ ); -++ const actionsCell = document.createElement("td"); -++ actionsCell.append(actions); -++ row.append( -++ createCell(emotion.emotionLabel), -++ createCell(String(emotion.pitch)), -++ createCell(String(emotion.rate)), -++ createCell(String(emotion.volume)), -++ createCell(labelForOption(TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS, emotion.ssmlLikePreset, "Normal")), -++ createCell(emotion.active ? "Active" : "Inactive"), -++ actionsCell, -++ ); -++ tbody.append(row); -++ }); -++ -++ if (state.editingEmotionId === NEW_ROW_KEY) { -++ tbody.append(createEmotionEditRow(null)); -++ } -++ -++ table.append(thead, tbody); -++ tableWrapper.append(table); -++ const actionGroup = document.createElement("div"); -++ actionGroup.className = "action-group"; -++ actionGroup.append(createButton("Add Emotion", "ttsAddEmotion", profileId)); -++ wrapper.append(tableWrapper, actionGroup); -++ return wrapper; - + } --+ if (!target) { --+ return visiblePlaybackError("Select a message or message part before playback."); -++ -++ function createEmotionEditRow(emotion = null) { -++ const key = emotion?.id || NEW_ROW_KEY; -++ const row = document.createElement("tr"); -++ row.dataset.ttsEmotionEditor = key; -++ const emotionCell = document.createElement("td"); -++ emotionCell.append(createEditorSelect(emotion?.emotion || "neutral", "ttsEmotionName", TTS_PROFILE_EMOTION_OPTIONS)); -++ const pitchCell = document.createElement("td"); -++ pitchCell.append(createNumberInput(emotion?.pitch ?? 1, "ttsEmotionPitch", "pitch")); -++ const rateCell = document.createElement("td"); -++ rateCell.append(createNumberInput(emotion?.rate ?? 1, "ttsEmotionRate", "rate")); -++ const volumeCell = document.createElement("td"); -++ volumeCell.append(createNumberInput(emotion?.volume ?? 1, "ttsEmotionVolume", "volume")); -++ const presetCell = document.createElement("td"); -++ presetCell.append(createEditorSelect(emotion?.ssmlLikePreset || "normal", "ttsEmotionSsmlPreset", TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS)); -++ const statusCell = document.createElement("td"); -++ statusCell.append(createCheckbox(emotion?.active !== false, "ttsEmotionActive")); -++ const actionsCell = document.createElement("td"); -++ actionsCell.append(createActionGroup( -++ createButton("Save", "ttsCommitEmotion", key), -++ createButton("Cancel", "ttsCancelEmotion", key), -++ )); -++ row.append(emotionCell, pitchCell, rateCell, volumeCell, presetCell, statusCell, actionsCell); -++ return row; - + } --+ if (!profile) { --+ return visiblePlaybackError("Select a TTS profile before playback."); -++ -++ function editorValue(rootNode, selector) { -++ return rootNode?.querySelector(selector)?.value || ""; - + } --+ if (!target.emotionProfile) { --+ return visiblePlaybackError("Selected message or part needs an emotion profile before playback."); -++ -++ function editorChecked(rootNode, selector) { -++ return rootNode?.querySelector(selector)?.checked !== false; - + } --+ if (!String(target.text || "").trim()) { --+ return visiblePlaybackError("Selected message or part needs text before playback."); -++ -++ function profileValues(key) { -++ const row = elements.profileTable?.querySelector(`[data-tts-profile-editor="${key}"]`); -++ const voiceValue = editorValue(row, "[data-tts-profile-voice]"); -++ const selectedVoice = state.voiceOptions.find((option) => option.value === voiceValue); -++ return createTextToSpeechProfile({ -++ active: editorChecked(row, "[data-tts-profile-active]"), -++ age: editorValue(row, "[data-tts-profile-age]"), -++ emotions: key === NEW_ROW_KEY ? [createTextToSpeechProfileEmotion()] : state.profiles.find((profile) => profile.id === key)?.emotions || [], -++ gender: editorValue(row, "[data-tts-profile-gender]"), -++ id: key === NEW_ROW_KEY ? "" : key, -++ language: editorValue(row, "[data-tts-profile-language]"), -++ name: editorValue(row, "[data-tts-profile-name]"), -++ voice: voiceValue, -++ voiceName: selectedVoice?.name || selectedVoice?.label || voiceValue, -++ }); - + } --+ return ttsServiceRegistry.speak(service.key, { -- language: profile.language, --- pitch: emotion.pitch, --- rate: emotion.rate, --+ pitch: target.emotionProfile.pitch ?? profile.pitch ?? 1, --+ rate: target.emotionProfile.rate ?? profile.rate ?? 1, -- speechItemId: target.id, -- speechItemName: target.name, -- text: target.text, -- voice: profile.voiceName, --- volume: emotion.volume, --+ volume: target.emotionProfile.volume ?? profile.volume ?? 1, -- }); --+} - + --+function segmentPlaybackTarget(segment) { --+ if (!segment) { --+ return null; -++ function validateProfile(profile) { -++ const errors = []; -++ if (!profile.name.trim()) errors.push("Profile Name is required."); -++ if (!profile.language.trim()) errors.push("Language is required."); -++ if (state.profiles.some((candidate) => candidate.id !== profile.id && candidate.name.toLowerCase() === profile.name.toLowerCase())) { -++ errors.push("Profile Name must be unique."); -++ } -++ return errors; - + } --+ return { --+ emotionProfile: emotionProfileByKey(segment.emotionProfileKey), --+ id: segment.key, --+ label: `Part ${segment.displayOrder}`, --+ name: `${segment.messageName || "Message"} part ${segment.displayOrder}`, --+ profile: selectedTtsProfileForSegment(segment.key, segment.messageKey), --+ text: segment.segmentText, --+ type: "part", --+ }; --+} - + --+function playPart(key) { --+ const segment = state.segments.find((candidate) => candidate.key === key); --+ const target = segmentPlaybackTarget(segment); --+ const result = speakTarget(playbackService(), target, target?.profile); -- if (!result.ok) { --- setText(elements.previewStatus, result.message || "Speech test failed."); --- setText(elements.log, result.message || "Speech test failed."); -- return; -- } --- const message = `Speech test started for ${target.label} using ${service.name}.`; --+ clearValidation(); --+ const message = `Play Part queued ${target.label} using ${target.profile.name}.`; --+ setText(elements.previewStatus, message); --+ setText(elements.log, message); --+} -++ function emotionValues(key) { -++ const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); -++ return createTextToSpeechProfileEmotion({ -++ active: editorChecked(row, "[data-tts-emotion-active]"), -++ emotion: editorValue(row, "[data-tts-emotion-name]"), -++ id: key === NEW_ROW_KEY ? "" : key, -++ pitch: editorValue(row, "[data-tts-emotion-pitch]"), -++ rate: editorValue(row, "[data-tts-emotion-rate]"), -++ ssmlLikePreset: editorValue(row, "[data-tts-emotion-ssml-preset]"), -++ volume: editorValue(row, "[data-tts-emotion-volume]"), -++ }); -++ } - + --+function playMessage(key) { --+ const messageRecord = state.messages.find((candidate) => candidate.key === key); --+ if (!messageRecord) { --+ visiblePlaybackError("Choose an existing message before playback."); --+ return; -++ function validateEmotion(emotion, existingId = "") { -++ const errors = []; -++ if (!state.selectedProfileId) errors.push("Select a TTS Profile before adding Emotion Settings."); -++ if (!emotion.emotion) errors.push("Emotion is required."); -++ const profile = selectedProfile(); -++ if (profile?.emotions.some((candidate) => candidate.id !== existingId && candidate.emotion === emotion.emotion)) { -++ errors.push("Emotion must be unique within the selected TTS Profile."); -++ } -++ return errors; - + } --+ const parts = messageSegments(messageRecord.key).filter((segment) => segment.active); --+ if (!parts.length) { --+ visiblePlaybackError("Add at least one active Message Part before playing this message."); --+ return; -++ -++ function addProfile() { -++ state.editingProfileId = NEW_ROW_KEY; -++ state.editingEmotionId = ""; -++ state.selectedProfileId = ""; -++ renderProfileRows(); -++ writeStatus("Ready to add a TTS profile."); - + } --+ const service = playbackService(); --+ for (const part of parts) { --+ const target = segmentPlaybackTarget(part); --+ const result = speakTarget(service, target, target?.profile || selectedTtsProfileForMessage(messageRecord.key)); --+ if (!result.ok) { -++ -++ function commitProfile(key) { -++ const profile = profileValues(key); -++ const errors = validateProfile(profile); -++ if (errors.length) { -++ writeStatus(`TTS Profile save blocked: ${errors.join(" ")}`, "FAIL"); - + return; - + } --+ } --+ clearValidation(); --+ const message = `Play Message queued ${parts.length} parts for ${messageRecord.name}.`; -- setText(elements.previewStatus, message); -- setText(elements.log, message); -- } --@@ -1043,31 +1115,6 @@ async function disableEmotion(key) { -- } -- } -- ---async function disableTts(key) { --- const profile = ttsProfileByKey(key); --- if (!profile) { --- return; --- } --- try { --- const result = updateTtsProfile(key, { --- active: false, --- description: profile.description, --- language: profile.language, --- name: profile.name, --- pitch: profile.pitch, --- providerKey: profile.providerKey, --- rate: profile.rate, --- voiceName: profile.voiceName, --- volume: profile.volume, --- }); --- await reloadAfterChange(); --- setText(elements.log, `Disabled TTS profile ${result.ttsProfile.name}.`); --- } catch (error) { --- showValidation([error instanceof Error ? error.message : String(error || "TTS profile status update failed.")]); --- setText(elements.log, "TTS profile status update failed."); --- } ---} --- -- async function moveSegment(key, direction) { -- const segments = selectedMessageSegments(); -- const currentIndex = segments.findIndex((segment) => segment.key === key); --@@ -1092,10 +1139,10 @@ async function moveSegment(key, direction) { -- segmentText: target.segmentText, -- }); -- await reloadAfterChange(state.selectedMessageKey, current.key); --- setText(elements.log, "Segment order updated."); --+ setText(elements.log, "Message part order updated."); -- } catch (error) { -- showValidation([error instanceof Error ? error.message : String(error || "Segment reorder failed.")]); --- setText(elements.log, "Segment reorder failed."); --+ setText(elements.log, "Message part reorder failed."); -- } -- } -- --@@ -1114,13 +1161,6 @@ elements.emotionAddRow?.addEventListener("click", () => { -- setText(elements.log, "Ready to add an emotion profile."); -- }); -- ---elements.ttsAddRow?.addEventListener("click", () => { --- clearValidation(); --- state.editingTtsKey = NEW_ROW_KEY; --- renderTtsRows(); --- setText(elements.log, "Ready to add a TTS profile."); ---}); --- -- elements.previewTtsProfile?.addEventListener("change", () => { -- renderSpeechTestControls(); -- }); --@@ -1137,14 +1177,40 @@ ttsServiceRegistry.onServicesChanged(() => { -- renderSpeechTestControls(); -- }); -- --+elements.table?.addEventListener("change", (event) => { --+ const messageSelect = event.target.closest("[data-message-default-tts-profile]"); --+ const segmentSelect = event.target.closest("[data-segment-tts-profile]"); --+ if (messageSelect) { --+ const key = messageSelect.dataset.messagesTtsIdentity || state.selectedMessageKey; --+ if (key && key !== NEW_ROW_KEY) { --+ state.messageTtsProfileKeys.set(key, messageSelect.value || defaultTtsProfileKey()); -++ if (key === NEW_ROW_KEY) { -++ state.profiles.push(profile); -++ } else { -++ const index = state.profiles.findIndex((candidate) => candidate.id === key); -++ const existing = state.profiles[index]; -++ state.profiles[index] = { -++ ...profile, -++ emotions: existing?.emotions || profile.emotions, -++ messageStudioUsageCount: existing?.messageStudioUsageCount || 0, -++ }; - + } --+ renderSpeechTestControls(); --+ setText(elements.log, "Default TTS profile selected for this message playback session."); -++ state.selectedProfileId = profile.id; -++ state.editingProfileId = ""; -++ renderProfileRows(); -++ renderOutputSummary(); -++ writeStatus(`Saved TTS profile: ${profile.name}.`); - + } --+ if (segmentSelect) { --+ const key = segmentSelect.dataset.messagesTtsIdentity || state.selectedSegmentKey; --+ if (key && key !== NEW_ROW_KEY) { --+ state.segmentTtsProfileKeys.set(key, segmentSelect.value || defaultTtsProfileKey()); -++ -++ function deleteProfile(key) { -++ const profile = state.profiles.find((candidate) => candidate.id === key); -++ if (!profile) return; -++ if (profileInUseByMessageStudio(profile)) { -++ writeStatus(`Delete profile disabled: ${profile.name} is in use by Message Studio data.`, "FAIL"); -++ return; - + } --+ renderSpeechTestControls(); --+ setText(elements.log, "TTS profile selected for this part playback session."); -++ state.profiles = state.profiles.filter((candidate) => candidate.id !== key); -++ if (state.selectedProfileId === key) state.selectedProfileId = state.profiles[0]?.id || ""; -++ renderProfileRows(); -++ renderOutputSummary(); -++ writeStatus(`Deleted TTS profile: ${profile.name}.`); - + } --+}); - + -- elements.table?.addEventListener("click", async (event) => { --+ if (event.target.closest("[data-message-default-tts-profile], [data-segment-tts-profile]")) { --+ return; -++ function addEmotion(profileId) { -++ state.selectedProfileId = profileId; -++ state.editingProfileId = ""; -++ state.editingEmotionId = NEW_ROW_KEY; -++ renderProfileRows(); -++ writeStatus("Ready to add an emotion setting."); - + } -- const row = event.target.closest("[data-messages-row]"); -- const segmentRow = event.target.closest("[data-messages-segment-row]"); --+ const playButton = event.target.closest("[data-messages-play]"); -- const editButton = event.target.closest("[data-messages-edit]"); -- const commitButton = event.target.closest("[data-messages-commit]"); -- const cancelButton = event.target.closest("[data-messages-cancel]"); -- const disableButton = event.target.closest("[data-messages-disable]"); -- const segmentAddButton = event.target.closest("[data-messages-segment-add-row]"); --+ const segmentPlayButton = event.target.closest("[data-messages-segment-play]"); -- const segmentEditButton = event.target.closest("[data-messages-segment-edit]"); -- const segmentCommitButton = event.target.closest("[data-messages-segment-commit]"); -- const segmentCancelButton = event.target.closest("[data-messages-segment-cancel]"); --@@ -1152,6 +1218,14 @@ elements.table?.addEventListener("click", async (event) => { -- const moveUpButton = event.target.closest("[data-messages-segment-move-up]"); -- const moveDownButton = event.target.closest("[data-messages-segment-move-down]"); -- --+ if (playButton) { --+ state.selectedMessageKey = playButton.dataset.messagesPlay; --+ state.selectedSegmentKey = ""; --+ state.editingSegmentKey = ""; --+ render(); --+ playMessage(playButton.dataset.messagesPlay); --+ return; -++ -++ function commitEmotion(key) { -++ const emotion = emotionValues(key); -++ const errors = validateEmotion(emotion, key === NEW_ROW_KEY ? "" : key); -++ if (errors.length) { -++ writeStatus(`Emotion setting save blocked: ${errors.join(" ")}`, "FAIL"); -++ return; -++ } -++ const profile = selectedProfile(); -++ if (!profile) return; -++ if (key === NEW_ROW_KEY) { -++ profile.emotions.push(emotion); -++ } else { -++ const index = profile.emotions.findIndex((candidate) => candidate.id === key); -++ const existing = profile.emotions[index]; -++ profile.emotions[index] = { -++ ...emotion, -++ messagePartsUsageCount: existing?.messagePartsUsageCount || 0, -++ }; -++ } -++ state.editingEmotionId = ""; -++ renderProfileRows(); -++ renderOutputSummary(); -++ writeStatus(`Saved emotion setting: ${emotion.emotionLabel}.`); - + } -- if (editButton) { -- clearValidation(); -- state.editingMessageKey = editButton.dataset.messagesEdit; --@@ -1180,7 +1254,17 @@ elements.table?.addEventListener("click", async (event) => { -- clearValidation(); -- state.editingSegmentKey = NEW_ROW_KEY; -- render(); --- setText(elements.log, "Ready to add a segment row."); --+ setText(elements.log, "Ready to add a message part."); --+ return; -++ -++ function deleteEmotion(key) { -++ const profile = selectedProfile(); -++ const emotion = profile?.emotions.find((candidate) => candidate.id === key); -++ if (!profile || !emotion) return; -++ if (emotionInUseByMessageParts(emotion)) { -++ writeStatus(`Delete emotion disabled: ${emotion.emotionLabel} is in use by Message Parts.`, "FAIL"); -++ return; -++ } -++ profile.emotions = profile.emotions.filter((candidate) => candidate.id !== key); -++ renderProfileRows(); -++ renderOutputSummary(); -++ writeStatus(`Deleted emotion setting: ${emotion.emotionLabel}.`); - + } --+ if (segmentPlayButton) { --+ const segment = state.segments.find((candidate) => candidate.key === segmentPlayButton.dataset.messagesSegmentPlay); --+ if (segment) { --+ state.selectedMessageKey = segment.messageKey; --+ state.selectedSegmentKey = segment.key; --+ render(); -++ -++ function selectProfile(profileId) { -++ if (!state.profiles.some((profile) => profile.id === profileId)) return; -++ state.selectedProfileId = state.selectedProfileId === profileId ? "" : profileId; -++ state.editingEmotionId = ""; -++ renderProfileRows(); -++ if (state.selectedProfileId) { -++ writeStatus(`Opened Emotion Settings for ${selectedProfile()?.name}.`); - + } --+ playPart(segmentPlayButton.dataset.messagesSegmentPlay); -- return; -- } -- if (segmentEditButton) { --@@ -1188,7 +1272,7 @@ elements.table?.addEventListener("click", async (event) => { -- state.editingSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; -- state.selectedSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; -- render(); --- setText(elements.log, "Segment row opened inline."); --+ setText(elements.log, "Message part opened inline."); -- return; -++ } -++ -+ function itemFromControls(overrides = {}) { -+ const currentItem = selectedItem(); -+ return createTextToSpeechQueueItem({ -+@@ -379,7 +1018,12 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech ++- `node --check toolbox/idea-board/index.js` ++- Changed-file syntax checks for Game Hub JavaScript. ++- Targeted Idea Board Playwright. ++- Targeted Game Hub Playwright if existing coverage exists. ++- `npm run test:workspace-v2` ++- `git diff --check` + -+ function renderOutputSummary() { -+ if (elements.outputSummary) { -+- elements.outputSummary.textContent = JSON.stringify(state.queue, null, 2); -++ elements.outputSummary.textContent = JSON.stringify({ -++ contractVersion: TTS_PROFILE_CONTRACT_VERSION, -++ messageStudioOptions: createMessageStudioTtsProfileOptions(state.profiles), -++ profiles: state.profiles, -++ queue: state.queue -++ }, null, 2); -+ } -+ setTextContent(root, "[data-tts-text-count]", String(String(elements.text?.value || "").length)); - } -- if (segmentCommitButton) { --@@ -1199,7 +1283,7 @@ elements.table?.addEventListener("click", async (event) => { -- state.editingSegmentKey = ""; -- clearValidation(); -- render(); --- setText(elements.log, "Segment row edit canceled."); --+ setText(elements.log, "Message part edit canceled."); -- return; -+@@ -556,6 +1200,16 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech -+ return result; - } -- if (segmentDisableButton) { --@@ -1253,34 +1337,6 @@ elements.emotionRows?.addEventListener("click", async (event) => { ++## ZIP + -++ function ensureDefaultProfiles() { -++ if (state.profiles.length) { -++ return; -++ } -++ state.profiles = createDefaultTextToSpeechProfiles(state.voiceOptions); -++ state.selectedProfileId = ""; -++ renderProfileRows(); -++ renderOutputSummary(); -++ } -++ -+ function refreshActionState() { -+ const hasText = Boolean(String(elements.text?.value || "").trim()); -+ const hasVoice = Boolean(elements.voice?.value); -+@@ -755,6 +1409,69 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech - } -- }); -- ---elements.ttsRows?.addEventListener("click", async (event) => { --- const editButton = event.target.closest("[data-messages-tts-edit]"); --- const commitButton = event.target.closest("[data-messages-tts-commit]"); --- const cancelButton = event.target.closest("[data-messages-tts-cancel]"); --- const disableButton = event.target.closest("[data-messages-tts-disable]"); --- if (editButton) { --- clearValidation(); --- state.editingTtsKey = editButton.dataset.messagesTtsEdit; --- renderTtsRows(); --- setText(elements.log, "TTS profile opened inline."); --- return; --- } --- if (commitButton) { --- await commitTts(commitButton.dataset.messagesTtsCommit); --- return; --- } --- if (cancelButton) { --- state.editingTtsKey = ""; --- clearValidation(); --- renderTtsRows(); --- setText(elements.log, "TTS profile edit canceled."); --- return; --- } --- if (disableButton) { --- await disableTts(disableButton.dataset.messagesTtsDisable); --- } ---}); --- -- try { -- await loadAll(); -- } catch (error) { ++Produce `tmp/PR_26171_044-idea-board-game-hub-project-flow_delta.zip` with repo-structured changed files only. +diff --git a/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/PLAN_PR.md b/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/PLAN_PR.md +new file mode 100644 +index 000000000..c93dea32e +--- /dev/null ++++ b/docs_build/pr/PR_26171_044-idea-board-game-hub-project-flow/PLAN_PR.md +@@ -0,0 +1,31 @@ ++# PR_26171_044-idea-board-game-hub-project-flow Plan + -+ function mountEvents() { -++ elements.addProfile?.addEventListener("click", addProfile); -++ elements.profileTable?.addEventListener("click", (event) => { -++ const addEmotionButton = event.target.closest("[data-tts-add-emotion]"); -++ const commitEmotionButton = event.target.closest("[data-tts-commit-emotion]"); -++ const cancelEmotionButton = event.target.closest("[data-tts-cancel-emotion]"); -++ const deleteEmotionButton = event.target.closest("[data-tts-delete-emotion]"); -++ const deleteProfileButton = event.target.closest("[data-tts-delete-profile]"); -++ const editEmotionButton = event.target.closest("[data-tts-edit-emotion]"); -++ const editProfileButton = event.target.closest("[data-tts-edit-profile]"); -++ const commitProfileButton = event.target.closest("[data-tts-commit-profile]"); -++ const cancelProfileButton = event.target.closest("[data-tts-cancel-profile]"); -++ const profileRow = event.target.closest("[data-tts-profile-row]"); -++ -++ if (commitProfileButton) { -++ commitProfile(commitProfileButton.dataset.ttsCommitProfile); -++ return; -++ } -++ if (cancelProfileButton) { -++ state.editingProfileId = ""; -++ renderProfileRows(); -++ writeStatus("TTS profile edit canceled."); -++ return; -++ } -++ if (editProfileButton) { -++ state.editingProfileId = editProfileButton.dataset.ttsEditProfile; -++ state.selectedProfileId = editProfileButton.dataset.ttsEditProfile; -++ state.editingEmotionId = ""; -++ renderProfileRows(); -++ writeStatus("TTS profile opened inline."); -++ return; -++ } -++ if (deleteProfileButton) { -++ deleteProfile(deleteProfileButton.dataset.ttsDeleteProfile); -++ return; -++ } -++ if (addEmotionButton) { -++ addEmotion(addEmotionButton.dataset.ttsAddEmotion); -++ return; -++ } -++ if (commitEmotionButton) { -++ commitEmotion(commitEmotionButton.dataset.ttsCommitEmotion); -++ return; -++ } -++ if (cancelEmotionButton) { -++ state.editingEmotionId = ""; -++ renderProfileRows(); -++ writeStatus("Emotion setting edit canceled."); -++ return; -++ } -++ if (editEmotionButton) { -++ state.editingEmotionId = editEmotionButton.dataset.ttsEditEmotion; -++ renderProfileRows(); -++ writeStatus("Emotion setting opened inline."); -++ return; -++ } -++ if (deleteEmotionButton) { -++ deleteEmotion(deleteEmotionButton.dataset.ttsDeleteEmotion); -++ return; -++ } -++ if (profileRow) { -++ selectProfile(profileRow.dataset.ttsProfileRow); -++ } -++ }); -+ elements.queueList?.addEventListener("click", (event) => { -+ const itemButton = event.target.closest("[data-tts-queue-item]"); -+ if (itemButton) selectItem(itemButton.dataset.ttsQueueItem || ""); -+@@ -849,14 +1566,18 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech -+ mountEvents(); -+ if (!engine.isSupported()) { -+ await loadQueue(); -++ ensureDefaultProfiles(); -+ markUnavailable(); -+ return; -+ } -+ setTextContent(root, "[data-tts-engine-label]", "Ready"); -+ setTextContent(root, "[data-tts-engine-status]", "Browser SpeechSynthesis is available for local preview."); -+ refreshVoices(); -++ ensureDefaultProfiles(); -+ engine.onVoicesChanged(() => { -+ refreshVoices(); -++ renderProfileRows(); -++ renderOutputSummary(); -+ writeStatus(`${TEXT_TO_SPEECH_DISPLAY_NAME} voices updated from browser SpeechSynthesis.`); -+ }); -+ await loadQueue(); -+@@ -883,9 +1604,14 @@ export { -+ TTS_LANGUAGES, -+ TTS_MESSAGE_STATUSES, -+ TTS_OWNERSHIP, -++ TTS_PROFILE_CONTRACT_VERSION, -+ TTS_PROVIDER_ADAPTER_PLAN, -+ createEmotionProfile, -++ createDefaultTextToSpeechProfiles, -++ createMessageStudioTtsProfileOptions, -+ createSpeechPreviewRequest, -++ createTextToSpeechProfile, -++ createTextToSpeechProfileEmotion, -+ createTtsMessage, -+ createVoiceProfile, -+ initializeTextToSpeechTool, -+diff --git a/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md -+new file mode 100644 -+index 000000000..fedefd391 -+--- /dev/null -++++ b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md -+@@ -0,0 +1,31 @@ -++# PR_26171_067 Instruction Compliance Checklist -++ -++## Required Reads -++ -++- PASS: Read `docs_build/dev/PROJECT_INSTRUCTIONS.md` before implementation. -++- PASS: Read `docs_build/dev/PROJECT_MULTI_PC.txt` before implementation. -++- PASS: Read repository `AGENTS.md` instructions from the active workspace context. -++- PASS: Read relevant target files before editing. -++ -++## Gate Checks -++ -++- PASS: Started from `main`. -++- PASS: Pulled latest `origin/main`. -++- PASS: Repo was clean before branch creation. -++- PASS: Created scoped branch `pr/26171-067-tts-profile-emotion-table-foundation`. -++- PASS: PR number `067` is odd and assigned to Laptop / Environment 2. -++- PASS: TTS Studio is within Laptop ownership. -++- PASS: Active path is `toolbox/text-to-speech/`. -++- PASS: No wrong `tools/text2speech/` path was created. -++- PASS: No database changes were made. -++- PASS: No placeholder-only provider blocking behavior was introduced. -++ -++## Required Artifacts -++ -++- PASS: PR-specific report created. -++- PASS: Parent-child table checklist created. -++- PASS: Message/TTS contract checklist created. -++- PASS: Validation report created. -++- PASS: Manual validation notes created. -++- PASS: `codex_review.diff` and `codex_changed_files.txt` will be generated from the final scoped diff. -++- PASS: Repo-structured delta ZIP will be created under `tmp/`. -+diff --git a/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md -+new file mode 100644 -+index 000000000..712c083b1 -+--- /dev/null -++++ b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md -+@@ -0,0 +1,17 @@ -++# PR_26171_067 Manual Validation Notes -++ -++## Notes -++ -++- Verified TTS Studio keeps the active path `toolbox/text-to-speech/`. -++- Verified the page uses Theme V2 classes and external JavaScript only. -++- Verified the profile table can open child Emotion Settings by clicking the profile row. -++- Verified `Default Balanced Profile` delete is disabled because it is marked in use by Message Studio data. -++- Verified default `Neutral` emotion delete is disabled because it is marked in use by Message Parts. -++- Verified Add Profile, Edit Profile, Add Emotion, and Edit Emotion inline rows through targeted Playwright validation. -++- Verified Message Studio remains separate and its existing TTS dropdown smoke path still passes. -++- Verified no `tools/text2speech/` path was created. -++- Verified no database files were changed. -++ -++## Follow Up -++ -++- Future persistence can connect TTS Studio profile authoring to the existing Local API profile contract once that API ownership is assigned. -+diff --git a/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md -+new file mode 100644 -+index 000000000..bf2819ba1 -+--- /dev/null -++++ b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md -+@@ -0,0 +1,26 @@ -++# PR_26171_067 Message TTS Contract Checklist -++ -++## Ownership -++ -++- PASS: Message Studio owns message text and ordered message parts. -++- PASS: TTS Studio owns reusable TTS Profiles and per-profile Emotion Settings. -++- PASS: `src/engine/audio/` remains the playback owner. -++- PASS: Audio playback results remain owned by the audio engine flow. -++- PASS: Message Studio and TTS Studio are not merged into one tool. -++ -++## Contract Readiness -++ -++- PASS: TTS Studio exposes `TTS_PROFILE_CONTRACT_VERSION` with value `tts-profile-emotion-v1`. -++- PASS: TTS Studio exports `createMessageStudioTtsProfileOptions`. -++- PASS: Exported profile options include stable keys, active state, display name, language, provider key, voice name, and active emotion settings. -++- PASS: Emotion settings include emotion key, display label, pitch, rate, volume, and SSML-like preset. -++- PASS: The output summary shows the contract version and Message Studio compatible profile options for local diagnostics. -++- PASS: Existing Message Studio dropdown smoke validation still passes. -++ -++## Boundaries -++ -++- PASS: No database changes were introduced. -++- PASS: No future provider behavior was hardcoded. -++- PASS: No browser-owned product data was introduced as source of truth. -++- PASS: Default profile data is limited to a local down-the-middle fallback until the API/data contract exists. -++- PASS: Existing Message Studio Local API profile shape remains untouched. -+diff --git a/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md -+new file mode 100644 -+index 000000000..065b3999d -+--- /dev/null -++++ b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md -+@@ -0,0 +1,38 @@ -++# PR_26171_067 Parent Child Table Checklist -++ -++## Parent Table -++ -++- PASS: Parent table label is `TTS Profiles`. -++- PASS: Parent table lives in `toolbox/text-to-speech/`. -++- PASS: Parent table columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. -++- PASS: Default rows include `Default Balanced Profile`, `Man Profile 1`, and `Woman Profile 2`. -++- PASS: Parent row click opens or closes the child Emotion Settings row. -++- PASS: One selected profile row owns the visible child subtable at a time. -++- PASS: Parent profile count is visible in the summary stats. -++ -++## Parent Actions -++ -++- PASS: Add Profile opens a new inline row below the parent table rows. -++- PASS: Edit Profile opens an inline edit row for the selected profile. -++- PASS: Save Profile validates required Profile Name and Language values. -++- PASS: Duplicate Profile Name is blocked with a visible actionable error. -++- PASS: Cancel Profile closes the inline editor without applying changes. -++- PASS: Delete Profile removes unused profiles. -++- PASS: Delete Profile is disabled when the profile has Message Studio usage. -++ -++## Child Table -++ -++- PASS: Child table label is `Emotion Settings`. -++- PASS: Child table opens under the selected TTS Profile row. -++- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. -++- PASS: Default neutral emotion is provided for every default profile. -++- PASS: Emotion count is visible in both the profile row and summary stats. -++ -++## Child Actions -++ -++- PASS: Add Emotion opens a new inline row in the child table. -++- PASS: Edit Emotion opens an inline edit row. -++- PASS: Save Emotion validates selected profile and unique emotion per profile. -++- PASS: Cancel Emotion closes the inline editor without applying changes. -++- PASS: Delete Emotion removes unused emotions. -++- PASS: Delete Emotion is disabled when the emotion has Message Parts usage. -+diff --git a/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md -+new file mode 100644 -+index 000000000..551954c0a -+--- /dev/null -++++ b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md -+@@ -0,0 +1,50 @@ -++# PR_26171_067 TTS Profile Emotion Table Foundation -++ -++## Summary -++ -++TTS Studio now presents a parent TTS Profiles table with an expandable child Emotion Settings table. The active tool remains `toolbox/text-to-speech/`, uses Theme V2, and keeps all JavaScript external. -++ -++## Scope -++ -++- Updated `toolbox/text-to-speech/index.html` to expose the requested parent and child table surfaces. -++- Updated `toolbox/text-to-speech/text2speech.js` to seed reusable profiles, render child emotion settings, support inline add/edit rows, and block delete actions when profile or emotion usage is marked by Message Studio data. -++- Added a TTS profile contract helper that returns Message Studio compatible options without moving ownership into Message Studio. -++- Updated targeted TTS browser and unit validation. -++ -++## Requirement Evidence -++ -++- PASS: Active path remains `toolbox/text-to-speech/`. -++- PASS: Parent table is TTS Profiles. -++- PASS: Clicking a profile row opens the child Emotion Settings subtable. -++- PASS: Parent rows include `Man Profile 1` and `Woman Profile 2`. -++- PASS: Parent columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. -++- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. -++- PASS: Add Profile opens an inline add row under the parent table. -++- PASS: Edit Profile opens an inline edit row. -++- PASS: Add Emotion opens an inline add row in the child table. -++- PASS: Edit Emotion opens an inline edit row. -++- PASS: Delete profile is disabled when usage count indicates Message Studio data uses it. -++- PASS: Delete emotion is disabled when usage count indicates Message Parts use it. -++- PASS: Default balanced profile and default neutral emotion are provided. -++- PASS: Message Studio compatible profile options are exported for a future API/data contract. -++- PASS: Message Studio and TTS Studio remain separate tools. -++- PASS: No database changes were made. -++- PASS: Theme V2 only; no page-local CSS, tool-local CSS, inline styles, style blocks, or inline handlers. -++ -++## Validation -++ -++- PASS: `node --check toolbox\text-to-speech\text2speech.js`. -++- PASS: `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs`. -++- PASS: `node --check tests\tools\Text2SpeechShell.test.mjs`. -++- PASS: HTML inline style/script/event scan for `toolbox/text-to-speech/index.html`. -++- PASS: `node --test tests/tools/Text2SpeechShell.test.mjs`. -++- PASS: `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list`. -++- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. -++- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). -++ -++## Out Of Scope -++ -++- No Message Studio merge. -++- No new provider behavior. -++- No generated audio export. -++- No database schema, seed, or persistence change. -+diff --git a/docs_build/dev/reports/PR_26171_067-validation.md b/docs_build/dev/reports/PR_26171_067-validation.md -+new file mode 100644 -+index 000000000..a119c9b79 -+--- /dev/null -++++ b/docs_build/dev/reports/PR_26171_067-validation.md -+@@ -0,0 +1,42 @@ -++# PR_26171_067 Validation Report -++ -++## Commands Run -++ -++- `git branch --show-current` -++ - PASS: started from `main`. -++- `git checkout main` -++ - PASS. -++- `git pull origin main` -++ - PASS: already up to date. -++- `git status --short` -++ - PASS: clean before branch creation. -++- `node --check toolbox\text-to-speech\text2speech.js` -++ - PASS. -++- `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs` -++ - PASS. -++- `node --check tests\tools\Text2SpeechShell.test.mjs` -++ - PASS. -++- `Select-String -Path toolbox\text-to-speech\index.html -Pattern ']+src=)|\son\w+=|style='` -++ - PASS: no matches. -++- `node --test tests/tools/Text2SpeechShell.test.mjs` -++ - PASS: 4 tests passed. -++- `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list` -++ - PASS: 2 tests passed. -++ - Covers default profiles, expandable Emotion Settings, inline add/edit rows, delete-disabled usage states, and existing speech composition. -++- `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list` -++ - PASS: 2 tests passed. -++ - Covers Message Studio smoke compatibility for the existing TTS dropdown and audio-engine path. -++- `npm run test:workspace-v2` -++ - PASS: 5 Project Workspace tests passed. -++ - Note: command name is legacy; user-facing language is Project Workspace. -++ -++## Coverage -++ -++- PASS: `docs_build/dev/reports/playwright_v8_coverage_report.txt` produced changed runtime JS coverage. -++- PASS: `toolbox/text-to-speech/text2speech.js` covered by targeted browser validation. -++ -++## Skipped -++ -++- Database validation skipped because no database schema, seed, or persistence implementation changed. -++- Full samples validation skipped because no samples changed. -++- External TTS provider validation skipped because this PR does not implement provider behavior. -diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt -index 6fbeaba58..21de9adb6 100644 ---- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt -+++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt -@@ -6,9 +6,10 @@ Missing changed runtime JS files are WARN, not FAIL. - Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. - - Changed runtime JS files considered: --(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 --(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 --(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 -+(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -+(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -+(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 - - Guardrail warnings: --(100%) none - no changed runtime JS coverage warnings -+(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file missing from coverage; advisory only -+(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only -diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt -index 578a19ddc..49e94f0d7 100644 ---- a/docs_build/dev/reports/playwright_v8_coverage_report.txt -+++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt -@@ -12,34 +12,33 @@ Note: entry percentages use function coverage when available, otherwise line cov - Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. - - Exercised tool entry points detected: --(83%) Toolbox Index - exercised 4 runtime JS files -+(80%) Toolbox Index - exercised 2 runtime JS files - (0%) Tool Template V2 - not exercised by this Playwright run - (56%) Theme V2 Shared JS - exercised 2 runtime JS files - - Changed runtime JS files covered: --(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 --(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 --(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 -+(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -+(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -+(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 - - Files with executed line/function counts where available: --(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 - (36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 - (38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 - (54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 --(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 - (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 -+(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 - (76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 --(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -+(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 - (100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 --(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 - - Uncovered or low-coverage changed JS files: --(100%) none - no low-coverage changed runtime JS files -+(0%) toolbox/messages/message-tts-service-registry.js - WARNING: uncovered changed runtime JS file; advisory only -+(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only - - Changed JS files considered: --(0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage - (0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage --(0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage --(64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage --(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage --(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage -+(0%) tests/playwright/tools/TextToSpeechFunctional.spec.mjs - changed JS file not collected as browser runtime coverage -+(0%) tests/tools/Text2SpeechShell.test.mjs - changed JS file not collected as browser runtime coverage -+(0%) toolbox/messages/message-tts-service-registry.js - changed JS file not collected as browser runtime coverage -+(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage -+(80%) toolbox/text-to-speech/text2speech.js - changed JS file with browser V8 coverage -diff --git a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs -index 17e2c0b62..8c964fecc 100644 ---- a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs -+++ b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs -@@ -106,7 +106,45 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as - - await expect(page.locator("[data-tts-voice-select]")).toContainText("Arcade Voice"); - await expect(page.locator("[data-tts-voice-count]")).toHaveText("2"); -- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Ready"); -+ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); -+ await expect(page.locator("[data-tts-emotion-count]")).toHaveText("3"); -+ await expect(page.locator("[data-tts-profile-table]")).toContainText("Default Balanced Profile"); -+ await expect(page.locator("[data-tts-profile-table]")).toContainText("Man Profile 1"); -+ await expect(page.locator("[data-tts-profile-table]")).toContainText("Woman Profile 2"); -+ await expect(page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).getByRole("button", { name: "Delete" })).toBeDisabled(); -+ await page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).click(); -+ await expect(page.getByRole("heading", { name: "Emotion Settings" })).toBeVisible(); -+ await expect(page.getByRole("columnheader", { name: "Emotion", exact: true })).toBeVisible(); -+ await expect(page.getByRole("columnheader", { name: "SSML-like Preset" })).toBeVisible(); -+ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Delete" })).toBeDisabled(); ++## Purpose + -+ await page.getByRole("button", { name: "Add Profile" }).click(); -+ await expect(page.locator("[data-tts-profile-editor='__new__']")).toBeVisible(); -+ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Creature Profile"); -+ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("neutral"); -+ await page.locator("[data-tts-commit-profile='__new__']").click(); -+ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile."); -+ await expect(page.locator("[data-tts-profile-table]")).toContainText("Creature Profile"); -+ await page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile" }).getByRole("button", { name: "Edit Profile" }).click(); -+ await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Creature Profile Updated"); -+ await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); -+ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile Updated."); -+ await page.getByRole("button", { name: "Add Emotion" }).click(); -+ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); -+ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]").fill("1.2"); -+ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]").fill("1.1"); -+ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]").fill("0.8"); -+ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-ssml-preset]").selectOption("whisper-ish"); -+ await page.locator("[data-tts-commit-emotion='__new__']").click(); -+ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); -+ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" })).toContainText("1.2"); -+ await page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" }).getByRole("button", { name: "Edit Emotion" }).click(); -+ await page.locator("[data-tts-emotion-editor] [data-tts-emotion-volume]").fill("0.7"); -+ await page.locator("[data-tts-emotion-editor] [data-tts-commit-emotion]").click(); -+ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); -+ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"contractVersion\": \"tts-profile-emotion-v1\""); -+ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"name\": \"Creature Profile Updated\""); ++Wire the Idea Board Create Project action into a creator-facing Game Hub project view while preserving table-first Idea Board behavior. + - await expect(page.locator("[data-tts-gender-select]")).toBeVisible(); - await expect(page.locator("[data-tts-language-select]")).toBeVisible(); - await expect(page.locator("[data-tts-age-select]")).toBeVisible(); -@@ -173,7 +211,7 @@ test("Text To Speech shows actionable error when browser speech synthesis is una - const failures = await openTextToSpeechPage(page, { speechAvailable: false }); - try { - await expect(page.getByRole("heading", { level: 1, name: "Text To Speech" })).toBeVisible(); -- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Unavailable"); -+ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); - await expect(page.locator("[data-tts-engine-status]")).toContainText("SpeechSynthesis is unavailable"); - await expect(page.locator("[data-tts-status]")).toContainText("Use a browser with Web Speech API support"); - await expect(page.locator("[data-tts-voice-select]")).toContainText("No browser voices available"); -diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs -index fbc6ac8e9..51e4f019f 100644 ---- a/tests/tools/Text2SpeechShell.test.mjs -+++ b/tests/tools/Text2SpeechShell.test.mjs -@@ -3,9 +3,14 @@ import test from "node:test"; - - import { - TTS_MESSAGE_STATUSES, -+ TTS_PROFILE_CONTRACT_VERSION, - TTS_PROVIDER_ADAPTER_PLAN, -+ createDefaultTextToSpeechProfiles, - createEmotionProfile, -+ createMessageStudioTtsProfileOptions, - createSpeechPreviewRequest, -+ createTextToSpeechProfile, -+ createTextToSpeechProfileEmotion, - createTtsMessage, - createVoiceProfile, - previewTtsMessage, -@@ -59,3 +64,46 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai - assert.equal(TTS_PROVIDER_ADAPTER_PLAN[0].status, "implemented"); - assert.ok(TTS_PROVIDER_ADAPTER_PLAN.slice(1).every((provider) => provider.status === "planned")); - }); ++## Scope + -+test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { -+ const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; -+ const defaults = createDefaultTextToSpeechProfiles(voiceOptions); -+ const custom = createTextToSpeechProfile({ -+ emotions: [ -+ createTextToSpeechProfileEmotion({ -+ emotion: "urgent", -+ pitch: 1.2, -+ rate: 1.1, -+ ssmlLikePreset: "whisper-ish", -+ volume: 0.8, -+ }), -+ ], -+ id: "custom-profile", -+ name: "Custom Profile", -+ voice: "test-voice", -+ voiceName: "Test Voice", -+ }); -+ const options = createMessageStudioTtsProfileOptions([custom]); ++- Create or link a Game Hub project when a Ready idea uses Create Project. ++- Move created Project ideas into a read-only Idea Board state with Open in Game Hub and Archive actions. ++- Keep Archived ideas hidden by default and restorable/deletable when visible. ++- Productionize Game Hub project display with Project Information and read-only Source Idea fields. ++- Add the smallest handoff/project-contract fix needed to complete the flow. + -+ assert.equal(TTS_PROFILE_CONTRACT_VERSION, "tts-profile-emotion-v1"); -+ assert.equal(defaults[0].name, "Default Balanced Profile"); -+ assert.equal(defaults[0].messageStudioUsageCount, 1); -+ assert.equal(defaults[0].emotions[0].emotionLabel, "Neutral"); -+ assert.equal(defaults[0].emotions[0].messagePartsUsageCount, 1); -+ assert.deepEqual(options, [{ -+ active: true, -+ emotionSettings: [{ -+ emotion: "urgent", -+ emotionLabel: "Urgent", -+ pitch: 1.2, -+ rate: 1.1, -+ ssmlLikePreset: "whisper-ish", -+ volume: 0.8, -+ }], -+ key: "custom-profile", -+ language: "en-US", -+ name: "Custom Profile", -+ providerKey: "browser-speech", -+ voiceName: "Test Voice", -+ }]); -+}); -diff --git a/toolbox/text-to-speech/index.html b/toolbox/text-to-speech/index.html -index f5b29b19a..e82dd9468 100644 ---- a/toolbox/text-to-speech/index.html -+++ b/toolbox/text-to-speech/index.html -@@ -98,14 +98,50 @@ - -
-
--

Speech Composition

--
-+

TTS Studio

-+
-
0Characters
-+
0TTS Profiles
-+
0Emotion Settings
-
0Voices
-
CheckingEngine
-
-
-
-+
-+
Reusable Speech Profiles
-+

TTS Profiles

-+
-+
-+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+
Profile NameVoiceLanguageGenderAgeEmotion CountStatusActions
Loading TTS profiles.
-+
-+
-+ -+
-+
-+
-+
-+
-+
-+
Local Preview
-+

Speech Composition

-+
- - -
-diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js -index 5ab111eeb..9e0af1f87 100644 ---- a/toolbox/text-to-speech/text2speech.js -+++ b/toolbox/text-to-speech/text2speech.js -@@ -2,6 +2,7 @@ import { - createTextToSpeechQueueItem, - filterTextToSpeechVoiceOptions, - shapeTextToSpeechOptions, -+ textToSpeechLanguageOptionsFromVoices, - textToSpeechPayloadGenderValue, - TextToSpeechEngine, - uniqueTextToSpeechId, -@@ -80,6 +81,27 @@ const TTS_PROVIDER_ADAPTER_PLAN = Object.freeze([ - } - ]); - -+const TTS_PROFILE_CONTRACT_VERSION = "tts-profile-emotion-v1"; -+const NEW_ROW_KEY = "__new__"; -+const DEFAULT_TTS_PROFILE_ID = "default-balanced-profile"; -+const DEFAULT_TTS_EMOTION_ID = "neutral"; ++## Validation + -+const TTS_PROFILE_GENDER_OPTIONS = Object.freeze([ -+ Object.freeze({ label: "Neutral", value: "neutral" }), -+ Object.freeze({ label: "Male", value: "male" }), -+ Object.freeze({ label: "Female", value: "female" }), -+ Object.freeze({ label: "Any", value: "any" }) -+]); ++- `node --check toolbox/idea-board/index.js` ++- Changed-file syntax checks for Game Hub JavaScript. ++- Targeted Idea Board Playwright. ++- Targeted Game Hub Playwright if existing coverage exists. ++- `npm run test:workspace-v2` ++- Do not run full samples smoke. + -+const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ -+ Object.freeze({ label: "Neutral", value: "neutral" }), -+ Object.freeze({ label: "Calm", value: "calm" }), -+ Object.freeze({ label: "Urgent", value: "urgent" }), -+ Object.freeze({ label: "Whisper", value: "whisper" }), -+ Object.freeze({ label: "Angry", value: "angry" }), -+ Object.freeze({ label: "Excited", value: "excited" }) -+]); ++## Reports + - function boundedNumber(value, { fallback, max, min, value: defaultValue }) { - const number = Number(value); - const fallbackValue = fallback ?? defaultValue ?? min; -@@ -139,6 +161,144 @@ function createVoiceProfile({ key = "browser-speech", name = "Browser Speech", p - }; - } - -+function slugFromText(value, fallback = "tts-profile") { -+ const slug = String(value || "") -+ .trim() -+ .toLowerCase() -+ .replace(/[^a-z0-9]+/g, "-") -+ .replace(/^-+|-+$/g, ""); -+ return slug || fallback; -+} ++- `docs_build/dev/reports/codex_review.diff` ++- `docs_build/dev/reports/codex_changed_files.txt` + -+function labelForOption(options, value, fallback = "") { -+ return options.find((option) => String(option.value) === String(value))?.label || fallback || String(value || ""); -+} ++## Delivery + -+function createTextToSpeechProfileEmotion({ -+ active = true, -+ emotion = "neutral", -+ id = "", -+ messagePartsUsageCount = 0, -+ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, -+ rate = TEXT_TO_SPEECH_DEFAULTS.rate, -+ ssmlLikePreset = TEXT_TO_SPEECH_DEFAULTS.ssmlLikePreset, -+ volume = TEXT_TO_SPEECH_DEFAULTS.volume -+} = {}) { -+ const emotionKey = slugFromText(emotion, DEFAULT_TTS_EMOTION_ID); -+ return { -+ active: active !== false, -+ emotion: emotionKey, -+ emotionLabel: labelForOption(TTS_PROFILE_EMOTION_OPTIONS, emotionKey, "Neutral"), -+ id: id || emotionKey, -+ messagePartsUsageCount: Math.max(0, Number(messagePartsUsageCount) || 0), -+ pitch: boundedNumber(pitch, TEXT_TO_SPEECH_RANGE_DEFAULTS.pitch), -+ rate: boundedNumber(rate, TEXT_TO_SPEECH_RANGE_DEFAULTS.rate), -+ ssmlLikePreset: TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS.some((option) => option.value === ssmlLikePreset) ? ssmlLikePreset : "normal", -+ volume: boundedNumber(volume, TEXT_TO_SPEECH_RANGE_DEFAULTS.volume) -+ }; -+} ++- Commit, push, create PR, merge after validation passes, return to `main`, pull latest `main`, and produce `tmp/PR_26171_044-idea-board-game-hub-project-flow_delta.zip`. +diff --git a/src/dev-runtime/persistence/tool-repositories/game-workspace-mock-repository.js b/src/dev-runtime/persistence/tool-repositories/game-workspace-mock-repository.js +index ad5602999..fff42032d 100644 +--- a/src/dev-runtime/persistence/tool-repositories/game-workspace-mock-repository.js ++++ b/src/dev-runtime/persistence/tool-repositories/game-workspace-mock-repository.js +@@ -156,8 +156,37 @@ export const GAME_WORKSPACE_SCHEMA = Object.freeze({ + game_members: Object.freeze(["gameId", "userKey", "permission", "role"]), + }); + ++function normalizeSourceIdea(sourceIdea) { ++ if (!sourceIdea || typeof sourceIdea !== "object") { ++ return null; ++ } + -+function createTextToSpeechProfile({ -+ active = true, -+ age = TEXT_TO_SPEECH_DEFAULTS.voiceAge, -+ emotions = [], -+ gender = "neutral", -+ id = "", -+ language = TEXT_TO_SPEECH_DEFAULTS.language, -+ messageStudioUsageCount = 0, -+ name = "Default Balanced Profile", -+ voice = "", -+ voiceName = "" -+} = {}) { -+ const profileName = String(name || "Default Balanced Profile").trim() || "Default Balanced Profile"; -+ const emotionRows = Array.isArray(emotions) && emotions.length -+ ? emotions.map((emotion) => createTextToSpeechProfileEmotion(emotion)) -+ : [createTextToSpeechProfileEmotion()]; -+ return { -+ active: active !== false, -+ age: String(age || TEXT_TO_SPEECH_DEFAULTS.voiceAge), -+ emotions: emotionRows, -+ gender: String(gender || "neutral"), -+ id: id || slugFromText(profileName, "tts-profile"), -+ language: String(language || TEXT_TO_SPEECH_DEFAULTS.language), -+ messageStudioUsageCount: Math.max(0, Number(messageStudioUsageCount) || 0), -+ name: profileName, -+ owner: TTS_OWNERSHIP.AUDIO, -+ providerKey: "browser-speech", -+ voice: String(voice || ""), -+ voiceName: String(voiceName || voice || "Default browser voice") -+ }; -+} ++ 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) ++ : []; + -+function defaultVoiceForProfile(voiceOptions = [], preferredGender = "") { -+ if (!voiceOptions.length) { ++ if (!idea && !pitch && !notes.length) { + return null; + } -+ const preferred = voiceOptions.find((option) => { -+ const text = `${option.name || ""} ${option.label || ""}`.toLowerCase(); -+ if (preferredGender === "male") return /\bmale\b|\bman\b|\bdavid\b|\bmark\b/.test(text); -+ if (preferredGender === "female") return /\bfemale\b|\bwoman\b|\bzira\b/.test(text); -+ return false; -+ }); -+ return preferred || voiceOptions[0]; -+} + -+function createDefaultTextToSpeechProfiles(voiceOptions = []) { -+ const balancedVoice = defaultVoiceForProfile(voiceOptions); -+ const manVoice = defaultVoiceForProfile(voiceOptions, "male") || balancedVoice; -+ const womanVoice = defaultVoiceForProfile(voiceOptions, "female") || voiceOptions[1] || balancedVoice; -+ return [ -+ createTextToSpeechProfile({ -+ emotions: [createTextToSpeechProfileEmotion({ messagePartsUsageCount: 1 })], -+ id: DEFAULT_TTS_PROFILE_ID, -+ language: balancedVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, -+ messageStudioUsageCount: 1, -+ name: "Default Balanced Profile", -+ voice: balancedVoice?.value || "", -+ voiceName: balancedVoice?.name || balancedVoice?.label || "Default browser voice" -+ }), -+ createTextToSpeechProfile({ -+ gender: "male", -+ id: "man-profile-1", -+ language: manVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, -+ name: "Man Profile 1", -+ voice: manVoice?.value || "", -+ voiceName: manVoice?.name || manVoice?.label || "Default browser voice" -+ }), -+ createTextToSpeechProfile({ -+ gender: "female", -+ id: "woman-profile-2", -+ language: womanVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, -+ name: "Woman Profile 2", -+ voice: womanVoice?.value || "", -+ voiceName: womanVoice?.name || womanVoice?.label || "Default browser voice" -+ }) -+ ]; ++ return { idea, pitch, notes }; +} + -+function createMessageStudioTtsProfileOptions(profiles = []) { -+ return profiles -+ .filter((profile) => profile?.active !== false) -+ .map((profile) => ({ -+ active: true, -+ emotionSettings: Array.isArray(profile.emotions) -+ ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ -+ emotion: emotion.emotion, -+ emotionLabel: emotion.emotionLabel, -+ pitch: emotion.pitch, -+ rate: emotion.rate, -+ ssmlLikePreset: emotion.ssmlLikePreset, -+ volume: emotion.volume -+ })) -+ : [], -+ key: profile.id, -+ language: profile.language, -+ name: profile.name, -+ providerKey: profile.providerKey || "browser-speech", -+ voiceName: profile.voiceName || profile.voice || "" -+ })); ++function cloneRow(row) { ++ const cloned = { ...row }; ++ const sourceIdea = normalizeSourceIdea(row.sourceIdea); ++ if (sourceIdea) { ++ cloned.sourceIdea = sourceIdea; ++ } else { ++ delete cloned.sourceIdea; ++ } ++ return cloned; +} + - function createSpeechPreviewRequest({ - pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, - rate = TEXT_TO_SPEECH_DEFAULTS.rate, -@@ -250,6 +410,7 @@ function queueItemMeta(item) { - function initializeTextToSpeechTool(root = document, { engine = new TextToSpeechEngine() } = {}) { - const elements = { - addItem: root.querySelector("[data-tts-add-item]"), -+ addProfile: root.querySelector("[data-tts-add-profile]"), - age: root.querySelector("[data-tts-age-select]"), - characterPreset: root.querySelector("[data-tts-character-preset-select]"), - clearStatus: root.querySelector("[data-tts-clear-status]"), -@@ -266,6 +427,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech - pause: root.querySelector("[data-tts-pause]"), - pitch: root.querySelector("[data-tts-pitch]"), - pitchValue: root.querySelector("[data-tts-pitch-value]"), -+ profileCount: root.querySelector("[data-tts-profile-count]"), -+ profileEmotionCount: root.querySelector("[data-tts-emotion-count]"), -+ profileTable: root.querySelector("[data-tts-profile-table]"), - queueList: root.querySelector("[data-tts-queue-list]"), - rate: root.querySelector("[data-tts-rate]"), - rateValue: root.querySelector("[data-tts-rate-value]"), -@@ -285,7 +449,11 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech - }; - const state = { - applyingItem: false, -+ editingEmotionId: "", -+ editingProfileId: "", -+ profiles: [], - queue: [], -+ selectedProfileId: "", - selectedItemId: "", - sliderOverrides: { pitch: false, rate: false, volume: false }, - voiceOptions: [] -@@ -330,6 +498,477 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + function cloneRows(rows) { +- return rows.map((row) => ({ ...row })); ++ return rows.map(cloneRow); + } + + function cloneTables(tables) { +@@ -227,6 +256,7 @@ export function createGameWorkspaceMockRepository() { + return { + ...game, + purpose: game.purpose || "Game", ++ sourceIdea: normalizeSourceIdea(game.sourceIdea), + ownerDisplayName: owner?.displayName || game.ownerKey, + members: getGameMembers(game.id), }; - } - -+ function selectedProfile() { -+ return state.profiles.find((profile) => profile.id === state.selectedProfileId) || null; -+ } -+ -+ function profileInUseByMessageStudio(profile) { -+ return Number(profile?.messageStudioUsageCount || 0) > 0; -+ } -+ -+ function emotionInUseByMessageParts(emotion) { -+ return Number(emotion?.messagePartsUsageCount || 0) > 0; -+ } -+ -+ function createCell(text) { -+ const cell = document.createElement("td"); -+ cell.textContent = text; -+ return cell; -+ } -+ -+ function createButton(label, dataName, value) { -+ const button = document.createElement("button"); -+ button.className = "btn btn--compact"; -+ button.type = "button"; -+ button.dataset[dataName] = value; -+ button.textContent = label; -+ return button; -+ } -+ -+ function createActionGroup(...buttons) { -+ const group = document.createElement("div"); -+ group.className = "action-group action-group--tight"; -+ buttons.filter(Boolean).forEach((button) => group.append(button)); -+ return group; -+ } -+ -+ function tableMessage(colSpan, text) { -+ const row = document.createElement("tr"); -+ const cell = document.createElement("td"); -+ cell.colSpan = colSpan; -+ cell.textContent = text; -+ row.append(cell); -+ return row; -+ } -+ -+ function createTextInput(value, dataName) { -+ const input = document.createElement("input"); -+ input.dataset[dataName] = ""; -+ input.type = "text"; -+ input.value = value || ""; -+ return input; -+ } -+ -+ function createNumberInput(value, dataName, kind) { -+ const input = document.createElement("input"); -+ const range = TEXT_TO_SPEECH_RANGE_DEFAULTS[kind] || TEXT_TO_SPEECH_RANGE_DEFAULTS.rate; -+ input.dataset[dataName] = ""; -+ input.type = "number"; -+ input.min = String(range.min); -+ input.max = String(range.max); -+ input.step = String(range.step); -+ input.value = formatRangeValue(value ?? range.value, kind); -+ return input; -+ } -+ -+ function createCheckbox(checked, dataName) { -+ const label = document.createElement("label"); -+ const input = document.createElement("input"); -+ input.dataset[dataName] = ""; -+ input.type = "checkbox"; -+ input.checked = checked !== false; -+ label.append(input, " Active"); -+ return label; -+ } -+ -+ function createEditorSelect(value, dataName, options, placeholder = "") { -+ const select = document.createElement("select"); -+ select.dataset[dataName] = ""; -+ if (placeholder) { -+ const placeholderOption = document.createElement("option"); -+ placeholderOption.value = ""; -+ placeholderOption.textContent = placeholder; -+ select.append(placeholderOption); -+ } -+ options.forEach((optionValue) => { -+ const option = document.createElement("option"); -+ option.value = String(optionValue.value); -+ option.textContent = optionValue.label; -+ select.append(option); -+ }); -+ select.value = options.some((optionValue) => String(optionValue.value) === String(value)) ? String(value) : String(options[0]?.value || ""); -+ return select; -+ } -+ -+ function voiceSelectOptions() { -+ return state.voiceOptions.length -+ ? state.voiceOptions.map((option) => ({ label: option.label, value: option.value })) -+ : [{ label: "No browser voices available", value: "" }]; -+ } -+ -+ function languageSelectOptions() { -+ const voiceLanguages = textToSpeechLanguageOptionsFromVoices(state.voiceOptions); -+ return voiceLanguages.length ? voiceLanguages : TEXT_TO_SPEECH_LANGUAGE_OPTIONS; -+ } -+ -+ function profileVoiceName(profile) { -+ const match = state.voiceOptions.find((option) => option.value === profile.voice); -+ return match?.name || match?.label || profile.voiceName || "No voice selected"; -+ } -+ -+ function renderProfileCounts() { -+ if (elements.profileCount) elements.profileCount.textContent = String(state.profiles.length); -+ if (elements.profileEmotionCount) { -+ const emotionCount = state.profiles.reduce((total, profile) => total + profile.emotions.length, 0); -+ elements.profileEmotionCount.textContent = String(emotionCount); -+ } -+ } -+ -+ function renderProfileRows() { -+ if (!elements.profileTable) return; -+ elements.profileTable.replaceChildren(); -+ -+ state.profiles.forEach((profile) => { -+ if (state.editingProfileId === profile.id) { -+ elements.profileTable.append(createProfileEditRow(profile)); -+ appendEmotionHost(profile.id); -+ return; -+ } -+ -+ const row = document.createElement("tr"); -+ row.dataset.ttsProfileRow = profile.id; -+ const nameCell = document.createElement("td"); -+ nameCell.dataset.ttsProfileNameCell = profile.id; -+ nameCell.textContent = `${state.selectedProfileId === profile.id ? "v" : ">"} ${profile.name}`; -+ const deleteButton = createButton("Delete", "ttsDeleteProfile", profile.id); -+ if (profileInUseByMessageStudio(profile)) { -+ deleteButton.disabled = true; -+ deleteButton.title = "Delete disabled: profile is in use by Message Studio data."; -+ } -+ const actions = createActionGroup( -+ createButton("Edit Profile", "ttsEditProfile", profile.id), -+ deleteButton, -+ ); -+ row.append( -+ nameCell, -+ createCell(profileVoiceName(profile)), -+ createCell(profile.language), -+ createCell(labelForOption(TTS_PROFILE_GENDER_OPTIONS, profile.gender, "Neutral")), -+ createCell(labelForOption(TEXT_TO_SPEECH_AGE_FILTER_OPTIONS, profile.age, "Any")), -+ createCell(String(profile.emotions.length)), -+ createCell(profile.active ? "Active" : "Inactive"), -+ (() => { -+ const cell = document.createElement("td"); -+ cell.append(actions); -+ return cell; -+ })(), -+ ); -+ elements.profileTable.append(row); -+ appendEmotionHost(profile.id); -+ }); -+ -+ if (state.editingProfileId === NEW_ROW_KEY) { -+ elements.profileTable.append(createProfileEditRow(null)); -+ } -+ -+ if (!state.profiles.length && state.editingProfileId !== NEW_ROW_KEY) { -+ elements.profileTable.append(tableMessage(8, "No TTS profiles yet.")); +@@ -373,6 +403,10 @@ export function createGameWorkspaceMockRepository() { + purpose, + status, + }; ++ const sourceIdea = normalizeSourceIdea(input.sourceIdea); ++ if (sourceIdea) { ++ game.sourceIdea = sourceIdea; + } -+ renderProfileCounts(); -+ } -+ -+ function createProfileEditRow(profile = null) { -+ const key = profile?.id || NEW_ROW_KEY; -+ const row = document.createElement("tr"); -+ row.dataset.ttsProfileEditor = key; -+ -+ const nameCell = document.createElement("td"); -+ nameCell.append(createTextInput(profile?.name || "", "ttsProfileName")); -+ const voiceCell = document.createElement("td"); -+ voiceCell.append(createEditorSelect(profile?.voice || "", "ttsProfileVoice", voiceSelectOptions(), "Select voice")); -+ const languageCell = document.createElement("td"); -+ languageCell.append(createEditorSelect(profile?.language || TEXT_TO_SPEECH_DEFAULTS.language, "ttsProfileLanguage", languageSelectOptions())); -+ const genderCell = document.createElement("td"); -+ genderCell.append(createEditorSelect(profile?.gender || "neutral", "ttsProfileGender", TTS_PROFILE_GENDER_OPTIONS)); -+ const ageCell = document.createElement("td"); -+ ageCell.append(createEditorSelect(profile?.age || TEXT_TO_SPEECH_DEFAULTS.voiceAge, "ttsProfileAge", TEXT_TO_SPEECH_AGE_FILTER_OPTIONS)); -+ const emotionCountCell = createCell(profile ? String(profile.emotions.length) : "1"); -+ const statusCell = document.createElement("td"); -+ statusCell.append(createCheckbox(profile?.active !== false, "ttsProfileActive")); -+ const actionsCell = document.createElement("td"); -+ actionsCell.append(createActionGroup( -+ createButton("Save", "ttsCommitProfile", key), -+ createButton("Cancel", "ttsCancelProfile", key), -+ )); -+ -+ row.append(nameCell, voiceCell, languageCell, genderCell, ageCell, emotionCountCell, statusCell, actionsCell); -+ return row; -+ } -+ -+ function appendEmotionHost(profileId) { -+ if (state.selectedProfileId !== profileId) return; -+ const hostRow = document.createElement("tr"); -+ hostRow.dataset.ttsEmotionHost = profileId; -+ const cell = document.createElement("td"); -+ cell.colSpan = 8; -+ cell.append(createEmotionTable(profileId)); -+ hostRow.append(cell); -+ elements.profileTable.append(hostRow); + + tables.games.push(game); + tables.game_members.push({ +diff --git a/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs b/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs +index 1787b86d8..1c1f742a9 100644 +--- a/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs ++++ b/tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs +@@ -15,6 +15,14 @@ const SUPABASE_ENV_KEYS = Object.freeze([ + let fakeSupabaseServer; + let previousSupabaseEnv; + ++function restoreEnvValue(key, value) { ++ if (value === undefined) { ++ delete process.env[key]; ++ return; + } ++ process.env[key] = value; ++} + -+ function createEmotionTable(profileId) { -+ const profile = state.profiles.find((candidate) => candidate.id === profileId); -+ const wrapper = document.createElement("div"); -+ wrapper.className = "content-stack"; -+ const context = document.createElement("div"); -+ context.className = "kicker"; -+ context.textContent = "TTS Profile / Emotion Settings"; -+ const heading = document.createElement("h3"); -+ heading.textContent = "Emotion Settings"; -+ wrapper.append(context, heading); -+ -+ const tableWrapper = document.createElement("div"); -+ tableWrapper.className = "table-wrapper"; -+ const table = document.createElement("table"); -+ table.className = "data-table"; -+ table.setAttribute("aria-label", "Emotion Settings"); -+ const thead = document.createElement("thead"); -+ const headerRow = document.createElement("tr"); -+ ["Emotion", "Pitch", "Rate", "Volume", "SSML-like Preset", "Status", "Actions"].forEach((label) => { -+ const header = document.createElement("th"); -+ header.scope = "col"; -+ header.textContent = label; -+ headerRow.append(header); + function startFakeSupabaseServer() { + const tables = { + roles: [], +@@ -123,6 +131,16 @@ test.afterAll(async () => { + + 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 = []; +@@ -235,12 +253,10 @@ test("Game Hub creates, opens, and deletes mock games", async ({ page }) => { + 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]")).toContainText("Game Hub records loaded from the project records service"); +- await expect(page.locator("[data-project-record-status]")).toContainText("authoritative keys managed by service"); +- await expect(page.locator("[data-project-record-status]")).toContainText("asset references linked to storage object keys: 0"); ++ 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-project-records-table]")).toContainText("Project records service"); +- await expect(page.locator("[data-project-records-table]")).toContainText("asset refs 0"); ++ await expect(page.locator("[data-source-idea-section]")).toContainText("No source idea yet"); + 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-current-user-role]")).toHaveText("Owner"); +@@ -257,7 +273,7 @@ test("Game Hub creates, opens, and deletes mock games", async ({ page }) => { + await page.getByRole("button", { name: "Create Game" }).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-project-records-table]")).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-workspace-log]")).toHaveText("Created and opened Launch Test Game."); + +@@ -287,11 +303,8 @@ 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]")).toContainText("guest browsing enabled; guest saving blocked"); +- await expect(page.locator("[data-project-record-status]")).toContainText("project records service"); +- await expect(page.locator("[data-project-record-status]")).toContainText("asset references linked to storage object keys: 0"); ++ 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.locator("[data-project-records-table]")).toContainText("Project records service"); + 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(); +@@ -301,7 +314,7 @@ test("Game Hub preserves guest browsing and blocks guest saves", async ({ page } + + await page.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("Guest browsing enabled; sign in required to save Game Hub project records."); ++ await expect(page.locator("[data-game-workspace-log]")).toHaveText("Sign in to create or update Game Hub projects."); + + await expectNoPageFailures(failures); + } finally { +@@ -309,7 +322,7 @@ test("Game Hub preserves guest browsing and blocks guest saves", async ({ page } + } + }); + +-test("Game Hub shows active-game API diagnostics without throwing", async ({ page }) => { ++test("Game Hub shows active-game errors without throwing", async ({ page }) => { + await page.route("**/api/toolbox/game-workspace/repositories/*/methods/getActiveGame", async (route) => { + await route.fulfill({ + body: JSON.stringify({ +@@ -326,7 +339,7 @@ test("Game Hub shows active-game API diagnostics without throwing", async ({ pag + 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 unavailable for validation."); ++ await expect(page.locator("[data-game-workspace-log]")).toContainText("Active game is temporarily unavailable."); + expect(failures.pageErrors).toEqual([]); + expect(failures.consoleErrors.filter((message) => !message.includes("status of 502"))).toEqual([]); + } finally { +@@ -356,7 +369,7 @@ test("Game Hub reports malformed active-game payloads without throwing", async ( + try { + await expect(page.locator("[data-active-game-name]")).toHaveText("No game open"); + await expect(page.locator("[data-current-user-role]")).toHaveText("Viewer"); +- await expect(page.locator("[data-game-workspace-log]")).toContainText("Active game response is malformed."); ++ await expect(page.locator("[data-game-workspace-log]")).toContainText("Active game is temporarily unavailable."); + await expect(page.getByLabel("Game Purpose")).toBeDisabled(); + + await expectNoPageFailures(failures); +@@ -395,7 +408,6 @@ test("Game Hub displays and edits game purpose and member role", async ({ page } + 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 expect(page.locator("[data-game-members-table]")).toContainText("Owner"); + + await page.getByLabel("Game Purpose").selectOption("Learning Game"); + await expect(page.locator("[data-active-game-purpose]")).toHaveText("Learning Game"); +@@ -407,7 +419,6 @@ test("Game Hub displays and edits game purpose and member role", async ({ page } + + await page.getByLabel("Current User Role").selectOption("Designer"); + await expect(page.locator("[data-current-user-role]")).toHaveText("Designer"); +- await expect(page.locator("[data-game-members-table]")).toContainText("Designer"); + await expect(page.locator("[data-game-workspace-log]")).toHaveText("Updated current user role to Designer."); + + await page.getByLabel("Game Purpose").selectOption("Capability Demo"); +@@ -435,23 +446,22 @@ test("Game Hub progress panels update from mock game state", async ({ page }) => + 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", +- "Repository Tables", +- "Team Members" ++ "Readiness Output" + ]); + await expect(page.locator("aside.tool-column").last().getByText("Readiness Output")).toHaveCount(0); +- await expect(page.locator("aside.tool-column").last().getByText("Repository Tables")).toHaveCount(0); +- await expect(page.locator("aside.tool-column").last().getByText("Team Members")).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]"); +- const missingRequirements = panel.querySelector("[data-missing-requirements]"); + return Boolean( ++ projectInformation && ++ sourceIdea && + staticOverlay && + outputPanels && +- missingRequirements && +- (staticOverlay.compareDocumentPosition(outputPanels) & Node.DOCUMENT_POSITION_FOLLOWING) && +- (outputPanels.compareDocumentPosition(missingRequirements) & Node.DOCUMENT_POSITION_FOLLOWING) ++ (projectInformation.compareDocumentPosition(sourceIdea) & Node.DOCUMENT_POSITION_FOLLOWING) && ++ (sourceIdea.compareDocumentPosition(staticOverlay) & Node.DOCUMENT_POSITION_FOLLOWING) && ++ (staticOverlay.compareDocumentPosition(outputPanels) & Node.DOCUMENT_POSITION_FOLLOWING) + ); + }); + expect(panelOrderIsCorrect).toBe(true); +@@ -460,9 +470,7 @@ test("Game Hub progress panels update from mock game state", async ({ page }) => + 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-table-counts], [data-game-table-counts]")).toContainText("games"); +- await expect(page.locator("[data-game-table-counts]")).toContainText("5"); +- await expect(page.locator("[data-game-members-table]")).toContainText("Owner"); ++ await expect(page.locator("[data-game-project-information]")).toContainText("Progress Review Game"); + + await page.getByRole("button", { name: "Delete Open Game" }).click(); + await expect(page.locator("[data-active-game-name]")).toHaveText("Demo Game"); +diff --git a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs +index 1a42e53d7..a18205fa4 100644 +--- a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs ++++ b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs +@@ -1,4 +1,5 @@ + import { expect, test } from "@playwright/test"; ++import { MOCK_DB_KEYS } from "../../../src/dev-runtime/persistence/mock-db-store.js"; + import { isBrowserExtensionNoise } from "../../helpers/browserExtensionNoise.mjs"; + import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; + +@@ -114,8 +115,18 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { + const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; ++ const previousSupabaseEnv = { ++ GAMEFOUNDRY_DATABASE_URL: process.env.GAMEFOUNDRY_DATABASE_URL, ++ GAMEFOUNDRY_SUPABASE_ANON_KEY: process.env.GAMEFOUNDRY_SUPABASE_ANON_KEY, ++ GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY: process.env.GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY, ++ GAMEFOUNDRY_SUPABASE_URL: process.env.GAMEFOUNDRY_SUPABASE_URL, ++ }; + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; ++ process.env.GAMEFOUNDRY_DATABASE_URL = "postgres://idea-board:test@127.0.0.1:5432/idea_board"; ++ process.env.GAMEFOUNDRY_SUPABASE_ANON_KEY = "idea-board-anon-key"; ++ process.env.GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY = "idea-board-service-role-key"; ++ process.env.GAMEFOUNDRY_SUPABASE_URL = `${server.baseUrl}/fake-supabase`; + const failedRequests = []; + const pageErrors = []; + const consoleErrors = []; +@@ -139,6 +150,32 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { + }); + + try { ++ 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, ++ }), ++ }); + }); -+ thead.append(headerRow); -+ const tbody = document.createElement("tbody"); -+ tbody.dataset.ttsEmotionTable = profileId; -+ -+ if (!profile?.emotions.length && state.editingEmotionId !== NEW_ROW_KEY) { -+ tbody.append(tableMessage(7, "No emotion settings for this profile.")); -+ } -+ -+ profile?.emotions.forEach((emotion) => { -+ if (state.editingEmotionId === emotion.id) { -+ tbody.append(createEmotionEditRow(emotion)); -+ return; -+ } -+ const row = document.createElement("tr"); -+ row.dataset.ttsEmotionRow = emotion.id; -+ const deleteButton = createButton("Delete", "ttsDeleteEmotion", emotion.id); -+ if (emotionInUseByMessageParts(emotion)) { -+ deleteButton.disabled = true; -+ deleteButton.title = "Delete disabled: emotion is in use by Message Parts."; -+ } -+ const actions = createActionGroup( -+ createButton("Edit Emotion", "ttsEditEmotion", emotion.id), -+ deleteButton, -+ ); -+ const actionsCell = document.createElement("td"); -+ actionsCell.append(actions); -+ row.append( -+ createCell(emotion.emotionLabel), -+ createCell(String(emotion.pitch)), -+ createCell(String(emotion.rate)), -+ createCell(String(emotion.volume)), -+ createCell(labelForOption(TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS, emotion.ssmlLikePreset, "Normal")), -+ createCell(emotion.active ? "Active" : "Inactive"), -+ actionsCell, -+ ); -+ tbody.append(row); ++ 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, ++ }), ++ }); + }); -+ -+ if (state.editingEmotionId === NEW_ROW_KEY) { -+ tbody.append(createEmotionEditRow(null)); -+ } -+ -+ table.append(thead, tbody); -+ tableWrapper.append(table); -+ const actionGroup = document.createElement("div"); -+ actionGroup.className = "action-group"; -+ actionGroup.append(createButton("Add Emotion", "ttsAddEmotion", profileId)); -+ wrapper.append(tableWrapper, actionGroup); -+ return wrapper; -+ } -+ -+ function createEmotionEditRow(emotion = null) { -+ const key = emotion?.id || NEW_ROW_KEY; -+ const row = document.createElement("tr"); -+ row.dataset.ttsEmotionEditor = key; -+ const emotionCell = document.createElement("td"); -+ emotionCell.append(createEditorSelect(emotion?.emotion || "neutral", "ttsEmotionName", TTS_PROFILE_EMOTION_OPTIONS)); -+ const pitchCell = document.createElement("td"); -+ pitchCell.append(createNumberInput(emotion?.pitch ?? 1, "ttsEmotionPitch", "pitch")); -+ const rateCell = document.createElement("td"); -+ rateCell.append(createNumberInput(emotion?.rate ?? 1, "ttsEmotionRate", "rate")); -+ const volumeCell = document.createElement("td"); -+ volumeCell.append(createNumberInput(emotion?.volume ?? 1, "ttsEmotionVolume", "volume")); -+ const presetCell = document.createElement("td"); -+ presetCell.append(createEditorSelect(emotion?.ssmlLikePreset || "normal", "ttsEmotionSsmlPreset", TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS)); -+ const statusCell = document.createElement("td"); -+ statusCell.append(createCheckbox(emotion?.active !== false, "ttsEmotionActive")); -+ const actionsCell = document.createElement("td"); -+ actionsCell.append(createActionGroup( -+ createButton("Save", "ttsCommitEmotion", key), -+ createButton("Cancel", "ttsCancelEmotion", key), -+ )); -+ row.append(emotionCell, pitchCell, rateCell, volumeCell, presetCell, statusCell, actionsCell); -+ return row; -+ } -+ -+ function editorValue(rootNode, selector) { -+ return rootNode?.querySelector(selector)?.value || ""; -+ } -+ -+ function editorChecked(rootNode, selector) { -+ return rootNode?.querySelector(selector)?.checked !== false; -+ } -+ -+ function profileValues(key) { -+ const row = elements.profileTable?.querySelector(`[data-tts-profile-editor="${key}"]`); -+ const voiceValue = editorValue(row, "[data-tts-profile-voice]"); -+ const selectedVoice = state.voiceOptions.find((option) => option.value === voiceValue); -+ return createTextToSpeechProfile({ -+ active: editorChecked(row, "[data-tts-profile-active]"), -+ age: editorValue(row, "[data-tts-profile-age]"), -+ emotions: key === NEW_ROW_KEY ? [createTextToSpeechProfileEmotion()] : state.profiles.find((profile) => profile.id === key)?.emotions || [], -+ gender: editorValue(row, "[data-tts-profile-gender]"), -+ id: key === NEW_ROW_KEY ? "" : key, -+ language: editorValue(row, "[data-tts-profile-language]"), -+ name: editorValue(row, "[data-tts-profile-name]"), -+ voice: voiceValue, -+ voiceName: selectedVoice?.name || selectedVoice?.label || voiceValue, ++ await page.request.post(`${server.baseUrl}/api/session/user`, { ++ data: { userKey: MOCK_DB_KEYS.users.user1 }, + }); -+ } -+ -+ function validateProfile(profile) { -+ const errors = []; -+ if (!profile.name.trim()) errors.push("Profile Name is required."); -+ if (!profile.language.trim()) errors.push("Language is required."); -+ if (state.profiles.some((candidate) => candidate.id !== profile.id && candidate.name.toLowerCase() === profile.name.toLowerCase())) { -+ errors.push("Profile Name must be unique."); -+ } -+ return errors; -+ } + await page.goto(`${server.baseUrl}/toolbox/idea-board/index.html`, { waitUntil: "networkidle" }); + await expect(page.getByRole("heading", { level: 1, name: "Idea Board" })).toBeVisible(); + await expectProductionCopy(page); +@@ -278,6 +315,13 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { + await expect(page.locator("[data-idea-board-notes-count='lantern-reef']")).toHaveText("0 Notes"); + await expect(page.locator("[data-idea-board-expanded-row='lantern-reef']")).toHaveCount(0); + ++ await page.locator("[data-idea-board-idea-cell='lantern-reef']").click(); ++ await expect(page.locator("[data-idea-board-expanded-row='lantern-reef']")).toBeVisible(); ++ await page.locator("[data-idea-board-add-note='lantern-reef']").click(); ++ await page.locator("[data-idea-board-note-input]").fill("Use dusk tide changes as the first Game Hub planning note."); ++ await page.locator("[data-idea-board-note-action='save']").click(); ++ await expect(page.locator("[data-idea-board-notes-count='lantern-reef']")).toHaveText("1 Note"); ++ + await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='edit']").click(); + await expect(page.locator("[data-idea-board-idea-input-row] [data-idea-board-idea-action]")).toHaveText(["Save", "Cancel"]); + await expect(page.locator("[data-idea-board-idea-status-input]")).toHaveCount(1); +@@ -287,10 +331,11 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { + await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action]")).toHaveText(["Edit", "Create Project", "Delete"]); + await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='create-project']").click(); + await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] td").nth(1)).toHaveText("Project"); +- await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action]")).toHaveText(["Edit", "Open Project", "Archive"]); ++ await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action]")).toHaveText(["Open in Game Hub", "Archive"]); ++ await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='edit']")).toHaveCount(0); + await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='delete']")).toHaveCount(0); +- await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='open-project']").click(); +- await expect(page.locator("[data-idea-board-status]")).toHaveText("Opening Lantern Reef."); ++ await expect(page.locator("[data-idea-board-add-note='lantern-reef']")).toHaveCount(0); ++ await expect(page.locator("[data-idea-board-notes-table='lantern-reef'] [data-idea-board-note-action]")).toHaveCount(0); + await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='archive']").click(); + await expect(page.locator("[data-idea-board-idea-row='lantern-reef']")).toHaveCount(0); + await page.locator("[data-idea-board-status-filter-option][value='Archived']").check(); +@@ -299,23 +344,25 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { + await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action]")).toHaveText(["Restore", "Delete"]); + await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='restore']").click(); + await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] td").nth(1)).toHaveText("Project"); +- await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='archive']").click(); +- await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action]")).toHaveText(["Restore", "Delete"]); +- await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='delete']").click(); +- await expect(page.locator("[data-idea-board-idea-row='lantern-reef']")).toHaveCount(0); +- await page.locator("[data-idea-board-filter-clear-all]").click(); +- await expect(page.locator("[data-idea-board-idea-row]")).toHaveCount(0); +- await expect(page.locator("[data-idea-board-add-idea]")).toBeVisible(); +- await page.locator("[data-idea-board-filter-select-all]").click(); +- await expect(page.locator("[data-idea-board-idea-row]")).toHaveCount(3); ++ await expect(page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action]")).toHaveText(["Open in Game Hub", "Archive"]); ++ await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='open-project']").click(); ++ await page.waitForURL(/\/toolbox\/game-workspace\/index\.html\?game=lantern-reef-\d+$/); ++ await expect(page.getByRole("heading", { level: 1, name: "Game Hub" })).toBeVisible(); ++ await expect(page.locator("[data-active-game-name]")).toHaveText("Lantern Reef"); ++ await expect(page.locator("[data-source-idea-display]")).toHaveText("Lantern Reef"); ++ await expect(page.locator("[data-source-idea-pitch]")).toHaveText("Guide light through a reef that rearranges at dusk."); ++ await expect(page.locator("[data-source-idea-notes]")).toContainText("Use dusk tide changes as the first Game Hub planning note."); ++ await expect(page.locator("main")).not.toContainText(/\bproject records\b|\bAPI\b|\bDB\b|\bmock\b|\bseed\b|\bdebug\b|\binternal\b/i); + +- expect(mutatingApiRequests).toEqual([]); ++ expect(mutatingApiRequests.some((request) => request.includes("/api/toolbox/game-workspace/repositories"))).toBe(true); ++ expect(mutatingApiRequests.some((request) => request.includes("/methods/createGame"))).toBe(true); + expect(failedRequests).toEqual([]); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); + } finally { + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); ++ Object.entries(previousSupabaseEnv).forEach(([key, value]) => restoreEnvValue(key, value)); + await server.close(); + } + }); +diff --git a/tests/playwright/tools/ToolboxRoutePages.spec.mjs b/tests/playwright/tools/ToolboxRoutePages.spec.mjs +index b2d410c0f..bfe555c39 100644 +--- a/tests/playwright/tools/ToolboxRoutePages.spec.mjs ++++ b/tests/playwright/tools/ToolboxRoutePages.spec.mjs +@@ -366,10 +366,6 @@ test("Idea Board launches from Toolbox with accordion table notes model", async + await page.locator("[data-idea-board-idea-status-input]").selectOption("Ready"); + await page.locator("[data-idea-board-idea-action='save']").click(); + await expect(page.locator("[data-idea-board-idea-row='launch-tile'] [data-idea-board-idea-action]")).toHaveText(["Edit", "Create Project", "Delete"]); +- await page.locator("[data-idea-board-idea-row='launch-tile'] [data-idea-board-idea-action='create-project']").click(); +- await expect(page.locator("[data-idea-board-idea-row='launch-tile'] td").nth(1)).toHaveText("Project"); +- await expect(page.locator("[data-idea-board-idea-row='launch-tile'] [data-idea-board-idea-action]")).toHaveText(["Edit", "Open Project", "Archive"]); +- await expect(page.locator("[data-idea-board-idea-row='launch-tile'] [data-idea-board-idea-action='delete']")).toHaveCount(0); + expect(mutatingApiRequests).toEqual([]); + + expect(failedRequests).toEqual([]); +diff --git a/toolbox/game-workspace/game-workspace.js b/toolbox/game-workspace/game-workspace.js +index d57ed37ef..90aea08f8 100644 +--- a/toolbox/game-workspace/game-workspace.js ++++ b/toolbox/game-workspace/game-workspace.js +@@ -3,12 +3,10 @@ import { + GAME_WORKSPACE_GAME_PURPOSES, + GAME_WORKSPACE_GAME_STATUSES, + createGameWorkspaceApiRepository, +- readProjectWorkspaceProjectRecords, + } from "./game-workspace-api-client.js"; + import { getSessionCurrent } from "../../src/api/session-api-client.js"; + + const repository = createGameWorkspaceApiRepository(); +-let projectRecordContract = null; + + const elements = { + activeGameName: document.querySelector("[data-active-game-name]"), +@@ -29,6 +27,10 @@ const elements = { + projectRecordStatus: document.querySelector("[data-project-record-status]"), + projectRecordsTable: document.querySelector("[data-project-records-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]"), +@@ -63,7 +65,7 @@ function isRepositoryErrorResult(value) { + } + + function repositoryErrorMessage(value, context) { +- return String(value?.message || value?.error || `${context} failed through the server API contract.`).trim(); ++ return `${context} is temporarily unavailable. Refresh the page or try again shortly.`; + } + + function reportRepositoryError(value, context) { +@@ -86,7 +88,7 @@ function normalizeActiveGame(value, context = "Active game") { + return null; + } + if (!isRecord(value) || !Array.isArray(value.members)) { +- setStatusLog(`${context} response is malformed. Reload Game Hub after the server API contract is restored.`); ++ setStatusLog(`${context} is temporarily unavailable. Refresh the page or try again shortly.`); + return null; + } + return value; +@@ -96,23 +98,23 @@ function normalizeProgress(value) { + if (reportRepositoryError(value, "Game progress")) { + return { + gameStatus: "No Game", +- gameProgress: "Blocked by server API error", +- publishingProgress: "Blocked", +- currentFocus: "Resolve the server API diagnostic", ++ gameProgress: "Progress is temporarily unavailable", ++ publishingProgress: "Unavailable", ++ currentFocus: "Refresh Game Hub", + recommendedNextTool: "Game Hub", + progressChecklist: [ +- { label: "Restore server API contract", status: "Blocked" }, ++ { label: "Project information", status: "Unavailable" }, + ], + }; + } + if (!isRecord(value)) { +- setStatusLog("Game progress response is malformed. Reload Game Hub after the server API contract is restored."); ++ 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 or seed a game", ++ currentFocus: "Create a game", + recommendedNextTool: "Game Hub", + progressChecklist: [], + }; +@@ -151,8 +153,8 @@ function refreshSaveControls() { + } + if (!saveAllowed) { + const currentStatus = String(elements.statusLog?.textContent || ""); +- if (!/Blocked|failed|malformed|Restore|Sign in required|unavailable/i.test(currentStatus)) { +- setStatusLog("Guest browsing enabled; sign in required to save Game Hub project records."); ++ if (!/failed|Sign in required|unavailable/i.test(currentStatus)) { ++ setStatusLog("Sign in to create or update Game Hub projects."); + } + } + } +@@ -161,7 +163,7 @@ function ensureProjectRecordsSaveAllowed(action) { + if (projectRecordsSaveAllowed()) { + return true; + } +- const message = `Sign in required to ${action} Game Hub project records.`; ++ const message = `Sign in required to ${action} Game Hub projects.`; + setStatusLog(message); + setProjectRecordStatus(message); + refreshSaveControls(); +@@ -209,53 +211,31 @@ function createGameButton(game, isActive) { + return button; + } + +-function renderProjectRecords() { ++function renderProjectInformation(activeGame, currentMember, progress) { + if (!elements.projectRecordsTable) { + return; + } + +- try { +- projectRecordContract = readProjectWorkspaceProjectRecords(); +- } catch (error) { +- projectRecordContract = null; +- setProjectRecordStatus(error instanceof Error ? error.message : "Game Hub project records are unavailable."); +- return; +- } +- +- const records = Array.isArray(projectRecordContract.records) ? projectRecordContract.records : []; +- const source = projectRecordContract.sourceLabel || "Project records service"; +- const assetReferenceCount = Number(projectRecordContract.assetReferenceCount || 0); +- const saveMode = projectRecordsSaveAllowed() +- ? "signed-in saves enabled" +- : "guest browsing enabled; guest saving blocked"; +- setProjectRecordStatus(`${projectRecordContract.terminology || "Game Hub"} records loaded from the project records service; authoritative keys managed by service; asset references linked to storage object keys: ${assetReferenceCount}; ${saveMode}.`); +- + elements.projectRecordsTable.replaceChildren(); +- if (!records.length) { +- const row = document.createElement("tr"); +- ["No records", "No Game Hub records", "Not started", source].forEach((value) => { +- const cell = document.createElement("td"); +- cell.textContent = value; +- row.append(cell); +- }); +- elements.projectRecordsTable.append(row); +- return; +- } +- +- records.forEach((record) => { +- const row = document.createElement("tr"); +- [ +- record.projectKey || "missing key", +- record.name || "Untitled project", +- record.status || "No status", +- `${source}; asset refs ${Number(record.assetReferenceCount || 0)}`, +- ].forEach((value) => { +- const cell = document.createElement("td"); +- cell.textContent = value; +- row.append(cell); +- }); +- elements.projectRecordsTable.append(row); ++ 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 emotionValues(key) { -+ const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); -+ return createTextToSpeechProfileEmotion({ -+ active: editorChecked(row, "[data-tts-emotion-active]"), -+ emotion: editorValue(row, "[data-tts-emotion-name]"), -+ id: key === NEW_ROW_KEY ? "" : key, -+ pitch: editorValue(row, "[data-tts-emotion-pitch]"), -+ rate: editorValue(row, "[data-tts-emotion-rate]"), -+ ssmlLikePreset: editorValue(row, "[data-tts-emotion-ssml-preset]"), -+ volume: editorValue(row, "[data-tts-emotion-volume]"), ++ setProjectRecordStatus(projectRecordsSaveAllowed() ++ ? "Project Information loaded." ++ : "Project Information loaded. Sign in to save changes."); + } + + function renderGameList() { +@@ -268,7 +248,7 @@ function renderGameList() { + const listResult = repository.listGames(gameUserKey ? { userKey: gameUserKey } : {}); + const games = Array.isArray(listResult) ? listResult : []; + if (!Array.isArray(listResult) && !reportRepositoryError(listResult, "Game list")) { +- setStatusLog("Game list response is malformed. Reload Game Hub after the server API contract is restored."); ++ setStatusLog("Game list is temporarily unavailable. Refresh the page or try again shortly."); + } + + elements.gameList.replaceChildren(); +@@ -276,7 +256,7 @@ function renderGameList() { + if (games.length === 0) { + const emptyState = document.createElement("p"); + emptyState.className = "status"; +- emptyState.textContent = "No games. Create or seed a game to continue."; ++ emptyState.textContent = "No games. Create a game to continue."; + elements.gameList.append(emptyState); + return; + } +@@ -343,7 +323,7 @@ function renderTableCounts() { + ? tableResult + : { users: [], games: [], game_members: [] }; + if ((!isRecord(tableResult) || isRepositoryErrorResult(tableResult)) && !reportRepositoryError(tableResult, "Repository tables")) { +- setStatusLog("Repository tables response is malformed. Reload Game Hub after the server API contract is restored."); ++ setStatusLog("Game Hub project details are temporarily unavailable. Refresh the page or try again shortly."); + } + const rows = [ + ["users", Array.isArray(tables.users) ? tables.users.length : 0], +@@ -366,6 +346,29 @@ function renderTableCounts() { + }); + } + ++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) ++ : []; ++ ++ 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); + }); + } ++} + -+ function validateEmotion(emotion, existingId = "") { -+ const errors = []; -+ if (!state.selectedProfileId) errors.push("Select a TTS Profile before adding Emotion Settings."); -+ if (!emotion.emotion) errors.push("Emotion is required."); -+ const profile = selectedProfile(); -+ if (profile?.emotions.some((candidate) => candidate.id !== existingId && candidate.emotion === emotion.emotion)) { -+ errors.push("Emotion must be unique within the selected TTS Profile."); -+ } -+ return errors; + function renderChecklist(progress) { + if (!elements.progressChecklist) { + return; +@@ -419,7 +422,8 @@ function renderWorkspace() { + renderMembersTable(activeGame); + renderTableCounts(); + renderChecklist(progress); +- renderProjectRecords(); ++ renderProjectInformation(activeGame, currentMember, progress); ++ renderSourceIdea(activeGame); + refreshSaveControls(); + } + +@@ -437,7 +441,7 @@ elements.form?.addEventListener("submit", (event) => { + + if (reportRepositoryError(game, "Create Game") || !isRecord(game) || !String(game.name || "").trim()) { + if (!isRepositoryErrorResult(game)) { +- setStatusLog("Create Game did not return a valid game. Restore the server API contract and try again."); ++ setStatusLog("Create Game could not be completed. Refresh the page or try again shortly."); + } + renderWorkspace(); + return; +@@ -528,4 +532,15 @@ elements.currentUserRoleInput?.addEventListener("change", () => { + populateSelect(elements.purposeInput, GAME_WORKSPACE_GAME_PURPOSES); + populateSelect(elements.gameStatusInput, GAME_WORKSPACE_GAME_STATUSES); + populateSelect(elements.currentUserRoleInput, GAME_WORKSPACE_MEMBER_ROLES); ++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")); ++ } else if (openedGame) { ++ 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 ac1017b27..f204166d8 100644 +--- a/toolbox/game-workspace/index.html ++++ b/toolbox/game-workspace/index.html +@@ -66,43 +66,58 @@ +
+ +
+-

Game Hub Overview

+-

Game Hub manages game identity, status, progress, and launch readiness through the project records service.

+-
Loading Game Hub project records.
+-
+- +- +- +- +- +- +- +- +- +- +- +- +- +-
Game Hub Project Records
Authoritative KeyProjectStatusSource
LoadingLoadingLoadingProject records service
+-
+-
++

Project Information

++

Review the open project and its source idea.

++
Project Information ready.
++
+
+-
+-
Open Game
+-

Demo Game

+-
+-
+-
Under ConstructionGame Status
+-
GameGame Purpose
+-
No ownerOwner
+-
OwnerCurrent User Role
+-
Game ConfigurationRecommended Next Tool
++
++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++
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.
++
++
++
+
+
+
+@@ -119,7 +134,7 @@ +

Recommended Next Tool

Game Configuration

+

Checklist

  • Game identity: Complete
+
+-
Demo Game seeded.
++
Game Hub ready.
+
+
+
+@@ -141,56 +156,7 @@ +
+
+ +-
+- Repository Tables +-
+-
+- +- +- +- +- +- +- +- +- +- +-
Game Hub data row counts
TableRows
users3
games4
game_members12
+-
+-
+-
+-
+- Team Members +-
+-
+- +- +- +- +- +- +- +- +-
Open game members
UserUser KeyRolePermission
No game---
+-
+-
+-
+
+- +
+
-

Game Hub Overview

-

Game Hub manages game identity, status, progress, and launch readiness through the project records service.

-
Loading Game Hub project records.
-
- - - - - - - - - - - - - -
Game Hub Project Records
Authoritative KeyProjectStatusSource
LoadingLoadingLoadingProject records service
-
-
+

Project Information

+

Review the open project and its source idea.

+
Project Information ready.
+
-
-
Open Game
-

Demo Game

-
-
-
Under ConstructionGame Status
-
GameGame Purpose
-
No ownerOwner
-
OwnerCurrent User Role
-
Game ConfigurationRecommended Next Tool
+
+ + + + + + + + + + + + + + + + + + + + + + +
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.
+
+
+
@@ -119,7 +134,7 @@

Game Progress

Recommended Next Tool

Game Configuration

Checklist

  • Game identity: Complete
-
Demo Game seeded.
+
Game Hub ready.
@@ -141,56 +156,7 @@

Game Progress

-
- Repository Tables -
-
- - - - - - - - - - -
Game Hub data row counts
TableRows
users3
games4
game_members12
-
-
-
-
- Team Members -
-
- - - - - - - - -
Open game members
UserUser KeyRolePermission
No game---
-
-
-
-