From 11d62b86e502f61fbd00da3e892da475477b817e Mon Sep 17 00:00:00 2001 From: DavidQ Date: Sat, 20 Jun 2026 15:47:03 -0400 Subject: [PATCH] PR_26171_065 message studio parent child table foundation --- ...71_065-instruction-compliance-checklist.md | 27 + .../PR_26171_065-manual-validation-notes.md | 26 + ...ge-studio-parent-child-table-foundation.md | 51 + ..._26171_065-parent-child-table-checklist.md | 47 + .../dev/reports/PR_26171_065-validation.md | 31 + .../dev/reports/codex_changed_files.txt | 127 +- docs_build/dev/reports/codex_review.diff | 1939 +++++++++++++---- .../reports/coverage_changed_js_guardrail.txt | 2 + .../reports/playwright_v8_coverage_report.txt | 43 +- tests/playwright/tools/MessagesTool.spec.mjs | 317 +-- toolbox/messages/index.html | 29 +- .../messages/message-tts-service-registry.js | 8 +- toolbox/messages/messages.js | 486 +++-- 13 files changed, 2173 insertions(+), 960 deletions(-) create mode 100644 docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md create mode 100644 docs_build/dev/reports/PR_26171_065-manual-validation-notes.md create mode 100644 docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md create mode 100644 docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md create mode 100644 docs_build/dev/reports/PR_26171_065-validation.md diff --git a/docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md new file mode 100644 index 000000000..5ea13ef0f --- /dev/null +++ b/docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md @@ -0,0 +1,27 @@ +# PR_26171_065 Instruction Compliance Checklist + +## Start Gate + +- PASS: Read `docs_build/dev/PROJECT_INSTRUCTIONS.md` before changes. +- PASS: Read `docs_build/dev/PROJECT_MULTI_PC.txt` before changes. +- PASS: Reported instruction compliance before implementation edits. +- PASS: Started from `main`. +- PASS: Pulled latest `origin/main`. +- PASS: Repository was clean before branch creation. +- PASS: Created branch `pr/26171-065-message-studio-parent-child-table-foundation`. + +## Ownership + +- PASS: PR number `065` is odd. +- PASS: Odd PR parity maps to Laptop / Environment 2. +- PASS: Messages belongs to Laptop / Environment 2 ownership. +- PASS: Active implementation path is `toolbox/messages/`. + +## Scope Controls + +- PASS: One PR purpose only. +- PASS: No database changes. +- PASS: No TTS Studio implementation. +- PASS: No future provider hardcoding. +- PASS: Scoped Message Studio validation ran. +- PASS: Project Workspace validation ran through the legacy command name. diff --git a/docs_build/dev/reports/PR_26171_065-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_065-manual-validation-notes.md new file mode 100644 index 000000000..e9af23e67 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_065-manual-validation-notes.md @@ -0,0 +1,26 @@ +# PR_26171_065 Manual Validation Notes + +## Manual Review + +- Confirmed active implementation path is `toolbox/messages/`. +- Confirmed no database files were changed. +- Confirmed Message Studio no longer exposes TTS profile creation/editing controls. +- Confirmed TTS profile dropdowns render from existing profile options with a default balanced fallback path in code. +- Confirmed `toolbox/messages/index.html` references Theme V2 and external scripts only. +- Confirmed no inline styles, style blocks, or inline event handlers are present. + +## Browser Validation Coverage + +- Opened Message Studio through the repo Playwright server. +- Added `Bat Encounter` as a parent message. +- Opened the child Message Parts table from the parent row. +- Added two ordered parts. +- Played the full message and verified two speech calls in part order. +- Played a single part and verified the speech call. +- Verified audio-engine-unavailable behavior shows visible actionable error text and does not create speech calls. + +## Out Of Scope Manual Checks + +- Did not exercise future TTS Studio profile authoring. +- Did not exercise external provider audio generation. +- Did not exercise database migration paths because this PR has no database changes. diff --git a/docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md b/docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md new file mode 100644 index 000000000..7fe5cf81c --- /dev/null +++ b/docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md @@ -0,0 +1,51 @@ +# PR_26171_065 Message Studio Parent Child Table Foundation + +## Summary + +Message Studio now presents a parent Messages table with an expandable child Message Parts table. The active tool remains `toolbox/messages/`, uses Theme V2, and keeps all JavaScript external. + +## Scope + +- Updated `toolbox/messages/index.html` to expose the requested parent and child table columns. +- Updated `toolbox/messages/messages.js` to render Messages and Message Parts, support inline add/edit rows, and provide row-level Play Message and Play Part actions. +- Updated `toolbox/messages/message-tts-service-registry.js` so the default balanced playback option can use the first available browser voice through `TextToSpeechEngine`. +- Updated targeted Message Studio Playwright validation. + +## Requirement Evidence + +- PASS: Active path remains `toolbox/messages/`. +- PASS: Parent table is Messages. +- PASS: Child accordion/subtable is Message Parts. +- PASS: Parent Message Name cell owns the visible expand/collapse cue. +- PASS: One parent expands at a time through existing selected message state. +- PASS: Parent columns are Message Name, Type, Status, Parts, Default TTS Profile, Actions. +- PASS: Child columns are Order, Text, Emotion, TTS Profile, Status, Actions. +- PASS: Empty state references the requested example `Bat Encounter` without creating browser-owned product data. +- PASS: Add Message opens an inline add row under the parent table. +- PASS: Edit Message opens an inline edit row. +- PASS: Add Part opens an inline add row in the child table. +- PASS: Edit Part opens an inline edit row. +- PASS: Play Part exists and uses the existing audio engine when available. +- PASS: Play Message exists and queues active parts in order when the audio engine is available. +- PASS: Audio engine unavailable state shows a visible actionable error. +- PASS: TTS profile selection offers existing profiles and a default balanced option if profiles are unavailable. +- PASS: TTS profile authoring is not owned by Message Studio in this PR. +- PASS: No database schema or seed changes were made. +- PASS: No browser-owned product data was introduced as source of truth. +- PASS: Theme V2 only; no page-local CSS, tool-local CSS, inline styles, style blocks, or inline handlers. + +## 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). + +## Out Of Scope + +- No TTS Studio implementation. +- No future provider behavior. +- No generated audio export. +- No new database persistence. diff --git a/docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md b/docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md new file mode 100644 index 000000000..e75c29b40 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md @@ -0,0 +1,47 @@ +# PR_26171_065 Parent Child Table Checklist + +## Parent Messages Table + +- PASS: Table label is Messages. +- PASS: Header includes Message Name. +- PASS: Header includes Type. +- PASS: Header includes Status. +- PASS: Header includes Parts. +- PASS: Header includes Default TTS Profile. +- PASS: Header includes Actions. +- PASS: Add Message creates an inline add row. +- PASS: Edit Message creates an inline edit row. +- PASS: Play Message button exists. +- PASS: Play Message queues active child parts by order. + +## Child Message Parts Table + +- PASS: Child table opens under the selected parent message. +- PASS: Child table label is Message Parts. +- PASS: Header includes Order. +- PASS: Header includes Text. +- PASS: Header includes Emotion. +- PASS: Header includes TTS Profile. +- PASS: Header includes Status. +- PASS: Header includes Actions. +- PASS: Add Part creates an inline add row. +- PASS: Edit Part creates an inline edit row. +- PASS: Play Part button exists. + +## Ownership Boundaries + +- PASS: Message Studio owns text and message part ordering. +- PASS: TTS Profile authoring is left to future TTS Studio work. +- PASS: Audio playback is delegated to the audio engine. +- PASS: Local API remains the source for message and part records. +- PASS: No browser storage or page-local product-data source of truth was added. + +## UI Constraints + +- PASS: Theme V2 only. +- PASS: External JavaScript only. +- PASS: No inline styles. +- PASS: No style blocks. +- PASS: No inline event handlers. +- PASS: No page-local CSS. +- PASS: No tool-local CSS. diff --git a/docs_build/dev/reports/PR_26171_065-validation.md b/docs_build/dev/reports/PR_26171_065-validation.md new file mode 100644 index 000000000..ab336606b --- /dev/null +++ b/docs_build/dev/reports/PR_26171_065-validation.md @@ -0,0 +1,31 @@ +# PR_26171_065 Validation Report + +## Commands Run + +- `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. +- `Select-String -Path toolbox\messages\index.html -Pattern ']+src=)|\son\w+=|style='` + - PASS: no matches. +- `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list` + - PASS: 2 tests passed. + - Covers parent Messages table, child Message Parts table, inline add/edit rows, ordered Play Message, Play Part, and unavailable audio engine error. +- `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: `docs_build/dev/reports/coverage_changed_js_guardrail.txt` reports no changed runtime JS coverage warnings. +- PASS: `toolbox/messages/messages.js` covered by targeted browser validation. +- PASS: `toolbox/messages/message-tts-service-registry.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/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/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 19a61b590..39d7f84a5 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,465 +1,1576 @@ -diff --git a/assets/theme-v2/js/tool-display-mode.js b/assets/theme-v2/js/tool-display-mode.js -index e77b7a9df..08bada607 100644 ---- a/assets/theme-v2/js/tool-display-mode.js -+++ b/assets/theme-v2/js/tool-display-mode.js -@@ -154,11 +154,6 @@ - body.appendChild(navigationRow); - } catch (error) { - console.warn("Tool navigation could not be loaded.", error); -- const diagnostic = document.createElement("p"); -- diagnostic.className = "status"; -- diagnostic.setAttribute("role", "status"); -- diagnostic.textContent = "Tool navigation is temporarily unavailable. Refresh the page or try again shortly."; -- body.appendChild(diagnostic); - } - } - -diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.tx -index 2a3c37ce3..15213f1d6 100644 ---- a/docs_build/dev/reports/codex_changed_files.tx -+++ b/docs_build/dev/reports/codex_changed_files.tx -@@ -1,54 +1,78 @@ --# Codex Changed Files - PR_26171_061-text2speech-engine-audio-feature-parity -- --## Git Workflow --- Starting branch: `main`. --- Created branch: `pr/26171-061-text2speech-engine-audio-feature-parity`. --- Initial commit: `e4541d63719ab777b0654c8fecf4b13237d31256`. --- PR branch was merged with `origin/main` during PR conflict recovery. --- Conflicts were limited to generated report artifacts. -- --## Scoped Diff Sta --```tex --...R_26171_061-engine-audio-ownership-checklist.md | 25 + -- ...R_26171_061-instruction-compliance-checklist.md | 32 + -- .../PR_26171_061-manual-validation-notes.md | 27 + -- ...R_26171_061-old-tts-feature-parity-checklist.md | 48 ++ -- ..._061-text2speech-engine-audio-feature-parity.md | 71 ++ -- docs_build/dev/reports/PR_26171_061-validation.md | 42 + -- docs_build/dev/reports/codex_changed_files.txt | 118 +-- -- .../dev/reports/coverage_changed_js_guardrail.txt | 7 +- -- .../dev/reports/playwright_v8_coverage_report.txt | 36 +- -- src/engine/audio/TextToSpeechEngine.js | 208 ++++- -- .../tools/TextToSpeechFunctional.spec.mjs | 30 + -- toolbox/text-to-speech/index.html | 119 +-- -- toolbox/text-to-speech/text2speech.js | 853 +++++++++++++++++---- -- 13 files changed, 1317 insertions(+), 299 deletions(-) --``` -- --## Changed Files --- src/engine/audio/TextToSpeechEngine.js --- toolbox/text-to-speech/index.html --- toolbox/text-to-speech/text2speech.js --- tests/playwright/tools/TextToSpeechFunctional.spec.mjs --- docs_build/dev/reports/coverage_changed_js_guardrail.tx --- docs_build/dev/reports/playwright_v8_coverage_report.tx --- docs_build/dev/reports/PR_26171_061-text2speech-engine-audio-feature-parity.md --- docs_build/dev/reports/PR_26171_061-instruction-compliance-checklist.md --- docs_build/dev/reports/PR_26171_061-old-tts-feature-parity-checklist.md --- docs_build/dev/reports/PR_26171_061-engine-audio-ownership-checklist.md --- docs_build/dev/reports/PR_26171_061-validation.md --- docs_build/dev/reports/PR_26171_061-manual-validation-notes.md --- docs_build/dev/reports/codex_review.diff --- docs_build/dev/reports/codex_changed_files.tx -+# PR_26171_042 Codex Changed Files Repor -+ -+## 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 +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 + -+- `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` ++## 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 ++``` + -+## Requirement Evidence ++## 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(-) ++``` + -+- 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. ++## 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 src\engine\audio\TextToSpeechEngine.js`. --- PASS: `node --check toolbox\text-to-speech\text2speech.js`. --- PASS: `node --test tests\tools\Text2SpeechShell.test.mjs`. --- PASS: `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs`. --- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). --- PASS: `git diff --check`. --- PASS: Post-conflict validation rerun after merging `origin/main` into the PR branch. -- --## ZIP --- Path: `tmp/PR_26171_061-text2speech-engine-audio-feature-parity_delta.zip`. -+ -+- 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 +- +-- 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). + -+- `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` -diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.tx -index 807990e9f..242796c4f 100644 ---- a/docs_build/dev/reports/coverage_changed_js_guardrail.tx -+++ b/docs_build/dev/reports/coverage_changed_js_guardrail.tx -@@ -6,8 +6,7 @@ Missing changed runtime JS files are WARN, not FAIL. - Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. ++## 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 +--- 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. Changed runtime JS files considered: --(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 --(71%) toolbox/text-to-speech/text2speech.js - executed lines 835/835; executed functions 61/86 -+(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 + (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 Guardrail warnings: (100%) none - no changed runtime JS coverage warnings -diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.tx -index dd573eabd..17eb943d2 100644 ---- a/docs_build/dev/reports/playwright_v8_coverage_report.tx -+++ b/docs_build/dev/reports/playwright_v8_coverage_report.tx -@@ -12,28 +12,45 @@ Note: entry percentages use function coverage when available, otherwise line cov +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 +--- 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 Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. Exercised tool entry points detected: --(72%) Toolbox Index - exercised 2 runtime JS files -+(72%) Toolbox Index - exercised 10 runtime JS files +-(72%) Toolbox Index - exercised 10 runtime JS files ++(83%) Toolbox Index - exercised 4 runtime JS files (0%) Tool Template V2 - not exercised by this Playwright run --(56%) Theme V2 Shared JS - exercised 2 runtime JS files -+(61%) Theme V2 Shared JS - exercised 7 runtime JS files +-(61%) Theme V2 Shared JS - exercised 7 runtime JS files ++(56%) Theme V2 Shared JS - exercised 2 runtime JS files Changed runtime JS files covered: --(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 --(71%) toolbox/text-to-speech/text2speech.js - executed lines 835/835; executed functions 61/86 -+(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 + (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 Files with executed line/function counts where available: --(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 --(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 209/209; executed functions 9/14 --(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 --(71%) toolbox/text-to-speech/text2speech.js - executed lines 835/835; executed functions 61/86 --(76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 --(100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 -+(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 -+(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 +-(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 + (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 Uncovered or low-coverage changed JS files: (100%) none - no low-coverage changed runtime JS files Changed JS files considered: --(0%) tests/playwright/tools/TextToSpeechFunctional.spec.mjs - changed JS file not collected as browser runtime coverage --(71%) src/engine/audio/TextToSpeechEngine.js - changed JS file with browser V8 coverage --(71%) toolbox/text-to-speech/text2speech.js - changed JS file with browser V8 coverage -+(0%) tests/playwright/tools/IdeaBoardTableNotes.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 -diff --git a/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md b/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md -new file mode 100644 -index 000000000..7dcde1b3e ---- /dev/null -+++ b/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md -@@ -0,0 +1,20 @@ -+# PR_26171_042-idea-board-navigation-fallback-cleanup Apply -+ -+## Apply Steps -+ -+1. Start from `main`. -+2. Pull latest `main`. -+3. Create `codex/pr-26171-042-idea-board-navigation-fallback-cleanup`. -+4. Implement the BUILD scope. -+5. Run requested validation. -+6. Stage scoped files only. -+7. Commit. -+8. Push branch. -+9. Create PR. -+10. Merge after validation passes. -+11. Return to `main` and pull latest. -+12. Confirm final report fields and repo-structured ZIP. -+ -+## Merge Conflict Handling -+ -+If conflicts occur, preserve latest `main`, preserve PR scope, resolve only touched files, rerun validation, regenerate reports and ZIP, then continue. -diff --git a/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md b/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md -new file mode 100644 -index 000000000..874f6dc4e ---- /dev/null -+++ b/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md -@@ -0,0 +1,57 @@ -+# PR_26171_042-idea-board-navigation-fallback-cleanup Build -+ -+## Source Of Truth -+ -+Use the user request for `PR_26171_042-idea-board-navigation-fallback-cleanup`, `docs_build/dev/PROJECT_INSTRUCTIONS.md`, `docs_build/dev/PROJECT_MULTI_PC.txt`, and this BUILD doc. -+ -+## Singular Purpose -+ -+Clean up the optional Tool Display Mode navigation fallback for Idea Board without changing Idea Board table behavior. -+ -+## Exact Targets -+ -+- `assets/theme-v2/js/tool-display-mode.js` -+- `toolbox/idea-board/index.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` -+ -+## Implementation Requirements -+ -+- Do not show creator-visible navigation diagnostics when optional Tool Display Mode navigation cannot load. -+- Remove the visible message `Tool navigation is temporarily unavailable. Refresh the page or try again shortly.` -+- Keep Idea Board usable when the navigation registry cannot load. -+- Log the navigation failure to console only. -+- Do not mention server, API, local server, port, registry, snapshot, or implementation details in creator-facing UI. -+- Do not let navigation failure affect Idea Board table functionality. -+- Validate both API-backed local route and static route without registry response. -+- In both cases, Idea Board renders, no creator-visible navigation error appears, and optional previous/next navigation is hidden or omitted when unavailable. -+- Ensure final Codex report fields are non-pending for PR URL, merge result, final main commit, created branch, and push result. -+ -+## Explicit Non-Goals -+ -+- Do not change Idea Board lifecycle behavior. -+- Do not change Show filter behavior. -+- Do not change Create Project behavior. -+- Do not change Archive behavior. -+- Do not change Chevron behavior. -+- Do not change table row editing behavior. -+ -+## Validation -+ -+- `node --check assets/theme-v2/js/tool-display-mode.js` -+- `node --check toolbox/idea-board/index.js` -+- `npx playwright test tests/playwright/tools/IdeaBoardTableNotes.spec.mjs --project=playwright --workers=1 --reporter=line --timeout=90000` -+- `npx playwright test tests/playwright/tools/ToolboxRoutePages.spec.mjs --project=playwright --workers=1 --reporter=line -g "Idea Board launches" --timeout=90000` -+- `npm run test:workspace-v2` -+- `git diff --check` + (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", + }); + }); + +@@ -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, { + -+## ZIP ++ 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(/.+/); + -+Create `tmp/PR_26171_042-idea-board-navigation-fallback-cleanup_delta.zip` with repo-structured changed files only. -diff --git a/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md b/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md -new file mode 100644 -index 000000000..3b5d5fbfe ---- /dev/null -+++ b/docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md -@@ -0,0 +1,36 @@ -+# PR_26171_042-idea-board-navigation-fallback-cleanup Plan ++ 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."); + -+## Purpose ++ 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."); + -+Fix the optional Tool Display Mode navigation fallback so Idea Board stays clean and usable when registry-backed previous/next navigation cannot load. ++ 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); + -+## Scope + 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 }); ++ } ++}); + +- 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 @@ +
+
+

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
+
+@@ -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, ++ }); + }, + }; + } +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; ++} + -+- Remove the creator-visible Tool Display Mode navigation fallback diagnostic. -+- Keep navigation failures logged to the browser console. -+- Preserve Idea Board lifecycle, filtering, project, archive, chevron, and row editing behavior. -+- Validate API-backed and static/no-registry Idea Board rendering. -+- Update final Codex reports with non-pending Git workflow fields. + 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]; ++} + -+## Required Validation ++function defaultTtsProfileKey() { ++ return activeTtsProfileOptions()[0]?.key || DEFAULT_TTS_PROFILE_KEY; ++} + -+- `node --check assets/theme-v2/js/tool-display-mode.js` -+- `node --check toolbox/idea-board/index.js` -+- Targeted Idea Board Playwright. -+- Targeted Toolbox route Playwright for Idea Board. -+- `npm run test:workspace-v2` -+- Do not run full samples smoke. ++function ttsProfileOptionByKey(profileKey) { ++ return activeTtsProfileOptions().find((profile) => profile.key === profileKey) ++ || activeTtsProfileOptions()[0] ++ || DEFAULT_TTS_PROFILE; ++} + -+## Required Reports ++function selectedTtsProfileForMessage(messageKey) { ++ return ttsProfileOptionByKey(state.messageTtsProfileKeys.get(messageKey) || defaultTtsProfileKey()); ++} + -+- `docs_build/dev/reports/codex_review.diff` -+- `docs_build/dev/reports/codex_changed_files.txt` ++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", + }; +@@ -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"); + -+## Required Delivery ++ const ttsCell = document.createElement("td"); ++ ttsCell.append(createTtsProfileSelect( ++ message ? selectedTtsProfileForMessage(message.key).key : defaultTtsProfileKey(), ++ "messageDefaultTtsProfile", ++ key, ++ )); + -+- Commit changes. -+- Push branch. -+- Create PR. -+- Merge after validation passes. -+- Return to main and pull latest main. -+- Produce repo-structured ZIP under `tmp/`. -diff --git a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs -index 38085a4d2..1a42e53d7 100644 ---- a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs -+++ b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs -@@ -105,6 +105,11 @@ async function expectProductionCopy(page) { + 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 })); -+async function expectNoNavigationFallbackUi(page) { -+ await expect(page.locator("body")).not.toContainText("Tool navigation is temporarily unavailable. Refresh the page or try again shortly."); -+ await expect(page.locator(".tool-display-mode")).not.toContainText(/\bserver\b|\bAPI\b|\blocal server\b|\bport\b|\bregistry\b|\bsnapshot\b/i); -+} ++ const textCell = document.createElement("td"); ++ textCell.append(createTextarea(segment?.segmentText || "", "segmentText", 3)); + - test("Idea Board uses accordion table ideas and notes", async ({ page }) => { - const server = await startRepoServer(); - const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; -@@ -137,6 +142,7 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { - 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); -+ await expectNoNavigationFallbackUi(page); - await expect(page.locator("[data-idea-board-table] > thead th[scope='col']")).toHaveText([ - "Idea", - "Pitch", -@@ -313,3 +319,55 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { - await server.close(); + 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; + } + +@@ -559,17 +626,28 @@ function renderMessageRows() { + + const row = document.createElement("tr"); + row.dataset.messagesRow = message.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, ++ )); + 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; ++ } ++ const message = `Speech test started for ${target.label} using ${service.name}.`; ++ setText(elements.previewStatus, message); ++ setText(elements.log, message); ++} + -+test("Idea Board remains usable without visible navigation fallback when registry navigation is unavailable", async ({ page }) => { -+ 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 pageErrors = []; -+ const consoleErrors = []; -+ const navigationWarnings = []; ++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 }; ++} + -+ await page.route("**/api/toolbox/registry/snapshot", async (route) => { -+ await route.fulfill({ body: "", status: 204 }); -+ }); ++function playbackService() { ++ return selectedTtsService() || state.ttsServices.find((service) => service.available) || null; ++} + -+ page.on("pageerror", (error) => { -+ const text = error.stack || error.message; -+ if (!isBrowserExtensionNoise(text)) pageErrors.push(error.message); -+ }); -+ page.on("console", (message) => { -+ const text = message.text(); -+ if (message.type() === "warning" && text.includes("Tool navigation could not be loaded.")) { -+ navigationWarnings.push(text); -+ } -+ if (message.type() === "error" && !isBrowserExtensionNoise(text)) consoleErrors.push(text); -+ }); ++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."); ++ } ++ if (!target) { ++ return visiblePlaybackError("Select a message or message part before playback."); ++ } ++ if (!profile) { ++ return visiblePlaybackError("Select a TTS profile before playback."); ++ } ++ if (!target.emotionProfile) { ++ return visiblePlaybackError("Selected message or part needs an emotion profile before playback."); ++ } ++ if (!String(target.text || "").trim()) { ++ return visiblePlaybackError("Selected message or part needs text before playback."); ++ } ++ 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, + }); ++} + -+ try { -+ 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); -+ await expectNoNavigationFallbackUi(page); -+ await expect(page.locator(".tool-display-mode__navigation-row")).toHaveCount(0); -+ await expect(page.locator("[data-idea-board-table]")).toBeVisible(); -+ await expect(page.locator("[data-idea-board-idea-row]")).toHaveCount(3); ++function segmentPlaybackTarget(segment) { ++ if (!segment) { ++ return null; ++ } ++ 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", ++ }; ++} + -+ await page.locator("[data-idea-board-idea-cell='top-thoughts']").click(); -+ await expect(page.locator("[data-idea-board-expanded-row='top-thoughts']")).toBeVisible(); -+ await page.locator("[data-idea-board-add-note='top-thoughts']").click(); -+ await page.locator("[data-idea-board-note-input]").fill("Navigation fallback does not block table notes."); -+ await page.locator("[data-idea-board-note-action='save']").click(); -+ await expect(page.locator("[data-idea-board-notes-table='top-thoughts']")).toContainText("Navigation fallback does not block table notes."); ++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); ++} + -+ expect(navigationWarnings.length).toBeGreaterThan(0); -+ expect(pageErrors).toEqual([]); -+ expect(consoleErrors).toEqual([]); -+ } finally { -+ restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); -+ restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); -+ await server.close(); ++function playMessage(key) { ++ const messageRecord = state.messages.find((candidate) => candidate.key === key); ++ if (!messageRecord) { ++ visiblePlaybackError("Choose an existing message before playback."); ++ return; + } -+}); -diff --git a/tests/playwright/tools/ToolboxRoutePages.spec.mjs b/tests/playwright/tools/ToolboxRoutePages.spec.mjs -index 9f40a19ad..b2d410c0f 100644 ---- a/tests/playwright/tools/ToolboxRoutePages.spec.mjs -+++ b/tests/playwright/tools/ToolboxRoutePages.spec.mjs -@@ -207,6 +207,11 @@ async function expectIdeaBoardProductionCopy(page) { - ); ++ 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; ++ } ++ 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) { ++ 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 expectNoToolNavigationFallbackUi(page) { -+ await expect(page.locator("body")).not.toContainText("Tool navigation is temporarily unavailable. Refresh the page or try again shortly."); -+ await expect(page.locator(".tool-display-mode")).not.toContainText(/\bserver\b|\bAPI\b|\blocal server\b|\bport\b|\bregistry\b|\bsnapshot\b/i); -+} +-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()); ++ } ++ renderSpeechTestControls(); ++ setText(elements.log, "Default TTS profile selected for this message playback session."); ++ } ++ if (segmentSelect) { ++ const key = segmentSelect.dataset.messagesTtsIdentity || state.selectedSegmentKey; ++ if (key && key !== NEW_ROW_KEY) { ++ state.segmentTtsProfileKeys.set(key, segmentSelect.value || defaultTtsProfileKey()); ++ } ++ renderSpeechTestControls(); ++ setText(elements.log, "TTS profile selected for this part playback session."); ++ } ++}); + - test("tools route aliases render toolbox tool pages", async ({ page }) => { - const server = await startRepoServer(); - const failedRequests = []; -@@ -304,6 +309,7 @@ test("Idea Board launches from Toolbox with accordion table notes model", async - await page.waitForLoadState("networkidle"); - await expect(page.getByRole("heading", { level: 1, name: "Idea Board" })).toBeVisible(); - await expectIdeaBoardProductionCopy(page); -+ await expectNoToolNavigationFallbackUi(page); - const ideaBoardSections = await page.locator("[data-idea-board-section]").evaluateAll((sections) => ( - sections.map((section) => section.getAttribute("data-idea-board-section")) - )); + elements.table?.addEventListener("click", async (event) => { ++ if (event.target.closest("[data-message-default-tts-profile], [data-segment-tts-profile]")) { ++ return; ++ } + 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; ++ } + 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; ++ } ++ if (segmentPlayButton) { ++ const segment = state.segments.find((candidate) => candidate.key === segmentPlayButton.dataset.messagesSegmentPlay); ++ if (segment) { ++ state.selectedMessageKey = segment.messageKey; ++ state.selectedSegmentKey = segment.key; ++ render(); ++ } ++ 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; + } + 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; + } + if (segmentDisableButton) { +@@ -1253,34 +1337,6 @@ elements.emotionRows?.addEventListener("click", async (event) => { + } + }); + +-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) { 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 --- 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. 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 Guardrail warnings: (100%) none - no changed runtime JS coverage warnings 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 --- 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 Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. Exercised tool entry points detected: -(72%) Toolbox Index - exercised 10 runtime JS files +(83%) Toolbox Index - exercised 4 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 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 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 (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 Uncovered or low-coverage changed JS files: (100%) none - no low-coverage changed runtime JS files 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", }); }); @@ -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 }); + } +}); - 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 @@

Studio Setup

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 @@

Studio Setup

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
@@ -58,17 +58,17 @@

Message Studio

Game Text Repository
-

Message Rows

+

Messages

- +
- - - - + + + + @@ -78,7 +78,7 @@

Message Rows

NamePrimary EmotionSegmentsTagsMessage NameType StatusPartsDefault TTS Profile Actions
- +
@@ -153,7 +150,7 @@

Inspector

Name: None

Emotion Profile: None

-

Segment: None

+

Part: None

Status: None

Text:

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

Inspector

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, + }); }, }; } 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; +} + 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 defaultTtsProfileKey() { + return activeTtsProfileOptions()[0]?.key || DEFAULT_TTS_PROFILE_KEY; +} + +function ttsProfileOptionByKey(profileKey) { + return activeTtsProfileOptions().find((profile) => profile.key === profileKey) + || activeTtsProfileOptions()[0] + || DEFAULT_TTS_PROFILE; +} + +function selectedTtsProfileForMessage(messageKey) { + return ttsProfileOptionByKey(state.messageTtsProfileKeys.get(messageKey) || defaultTtsProfileKey()); +} + +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", }; @@ -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; } @@ -559,17 +626,28 @@ function renderMessageRows() { const row = document.createElement("tr"); row.dataset.messagesRow = message.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, + )); 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; + } + 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 playbackService() { + return selectedTtsService() || state.ttsServices.find((service) => service.available) || null; +} + +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."); + } + if (!target) { + return visiblePlaybackError("Select a message or message part before playback."); + } + if (!profile) { + return visiblePlaybackError("Select a TTS profile before playback."); + } + if (!target.emotionProfile) { + return visiblePlaybackError("Selected message or part needs an emotion profile before playback."); + } + if (!String(target.text || "").trim()) { + return visiblePlaybackError("Selected message or part needs text before playback."); + } + 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; + } + 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 playMessage(key) { + const messageRecord = state.messages.find((candidate) => candidate.key === key); + if (!messageRecord) { + visiblePlaybackError("Choose an existing message before playback."); + return; + } + 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; + } + 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) { + 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()); + } + renderSpeechTestControls(); + setText(elements.log, "Default TTS profile selected for this message playback session."); + } + if (segmentSelect) { + const key = segmentSelect.dataset.messagesTtsIdentity || state.selectedSegmentKey; + if (key && key !== NEW_ROW_KEY) { + state.segmentTtsProfileKeys.set(key, segmentSelect.value || defaultTtsProfileKey()); + } + renderSpeechTestControls(); + setText(elements.log, "TTS profile selected for this part playback session."); + } +}); + elements.table?.addEventListener("click", async (event) => { + if (event.target.closest("[data-message-default-tts-profile], [data-segment-tts-profile]")) { + return; + } 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; + } 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; + } + if (segmentPlayButton) { + const segment = state.segments.find((candidate) => candidate.key === segmentPlayButton.dataset.messagesSegmentPlay); + if (segment) { + state.selectedMessageKey = segment.messageKey; + state.selectedSegmentKey = segment.key; + render(); + } + 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; } 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; } if (segmentDisableButton) { @@ -1253,34 +1337,6 @@ elements.emotionRows?.addEventListener("click", async (event) => { } }); -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) {