From 7415046cd65c53b24ba0b68aaf38fcf4b4c72517 Mon Sep 17 00:00:00 2001 From: Team Bravo Date: Tue, 23 Jun 2026 12:14:15 -0400 Subject: [PATCH 1/4] PR_26174_BRAVO_messages_tts_stack --- ...6174_BRAVO_001-messages-table-structure.md | 65 + ...174_BRAVO_002-message-parts-child-table.md | 60 + ..._26174_BRAVO_003-emotion-profiles-table.md | 54 + ...PR_26174_BRAVO_004-voice-profiles-table.md | 55 + ...26174_BRAVO_005-message-reference-usage.md | 51 + .../PR_26174_BRAVO_006-browser-tts-runtime.md | 49 + ..._26174_BRAVO_007-tts-provider-framework.md | 49 + ...R_26174_BRAVO_008-message-event-actions.md | 50 + ...74_BRAVO_009-message-publish-validation.md | 55 + ...010-separate-messages-and-tts-ownership.md | 63 + ...AVO_011-message-sentence-action-buttons.md | 49 + ...74_BRAVO_012-tts-preview-action-cleanup.md | 45 + ...O_013-message-and-sentence-play-buttons.md | 51 + ..._014-message-play-button-regression-fix.md | 45 + ..._015-child-play-uses-parent-tts-profile.md | 46 + ...AVO_017-message-play-profile-resolution.md | 50 + ..._BRAVO_018-fix-messages-playback-source.md | 48 + ...e-preview-dependency-from-messages-play.md | 53 + ..._020-messages-load-tts-profile-emotions.md | 68 + ...1-wire-messages-to-tts-profile-contract.md | 65 + .../dev/reports/codex_changed_files.txt | 14 +- docs_build/dev/reports/codex_review.diff | 1058 +++++++++-- .../messages/messages-postgres-service.mjs | 666 ++++++- tests/dev-runtime/DbSeedIntegrity.test.mjs | 13 +- .../MessagesPublishValidation.test.mjs | 132 ++ tests/helpers/messagesPostgresClientStub.mjs | 15 + tests/playwright/tools/EventsTool.spec.mjs | 138 ++ tests/playwright/tools/MessagesTool.spec.mjs | 751 +++++--- .../tools/TextToSpeechFunctional.spec.mjs | 89 +- tests/tools/MessagesPlaybackSource.test.mjs | 28 + tests/tools/Text2SpeechShell.test.mjs | 13 +- toolbox/events/events.js | 349 ++++ toolbox/events/index.html | 52 +- toolbox/messages/index.html | 64 +- toolbox/messages/messages-api-client.js | 21 + toolbox/messages/messages.js | 1545 ++++++++++------- toolbox/text-to-speech/index.html | 8 +- toolbox/text-to-speech/text2speech.js | 153 +- 38 files changed, 4972 insertions(+), 1208 deletions(-) create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_001-messages-table-structure.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_002-message-parts-child-table.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_003-emotion-profiles-table.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_004-voice-profiles-table.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_005-message-reference-usage.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_006-browser-tts-runtime.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_007-tts-provider-framework.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_008-message-event-actions.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_009-message-publish-validation.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_010-separate-messages-and-tts-ownership.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_011-message-sentence-action-buttons.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_012-tts-preview-action-cleanup.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_013-message-and-sentence-play-buttons.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_014-message-play-button-regression-fix.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_015-child-play-uses-parent-tts-profile.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_017-message-play-profile-resolution.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_018-fix-messages-playback-source.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_019-remove-preview-dependency-from-messages-play.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_020-messages-load-tts-profile-emotions.md create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md create mode 100644 tests/dev-runtime/MessagesPublishValidation.test.mjs create mode 100644 tests/playwright/tools/EventsTool.spec.mjs create mode 100644 tests/tools/MessagesPlaybackSource.test.mjs create mode 100644 toolbox/events/events.js diff --git a/docs_build/dev/reports/PR_26174_BRAVO_001-messages-table-structure.md b/docs_build/dev/reports/PR_26174_BRAVO_001-messages-table-structure.md new file mode 100644 index 000000000..d2242acf3 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_001-messages-table-structure.md @@ -0,0 +1,65 @@ +# PR_26174_BRAVO_001-messages-table-structure + +## Summary + +Converted Message Studio to the approved table-first Messages surface with server-owned persistence, inline row editing, and guarded message deletion. The visible table now uses Theme V2 table classes and exposes Message, Text, Tags, Emotion, Voice, Updated, and Actions columns. + +## Branch Validation + +| Check | Result | Notes | +| --- | --- | --- | +| Current branch is main | PASS | `git branch --show-current` returned `main`. | + +## Requirement Checklist + +| Requirement | Result | Notes | +| --- | --- | --- | +| Convert existing Messages tool/page to approved GFS table-first layout | PASS | `toolbox/messages/index.html` and `toolbox/messages/messages.js` now render the Messages table as the primary center surface. | +| Columns: Message, Text, Tags, Emotion, Voice, Updated, Actions | PASS | Static table headers match the requested seven-column structure. | +| Add Message button above/near table | PASS | Added a Theme V2 button in the table surface header. | +| Row actions: Edit, Delete | PASS | Saved rows render Edit and Delete only. | +| Edit mode actions: Save, Cancel | PASS | Inline editor rows render Save and Cancel only. | +| Delete blocked/disabled when referenced | PASS | UI disables Delete when message parts reference the message; service delete path returns 409 for referenced rows. | +| Use Theme V2 and existing reusable table patterns | PASS | Reused `surface-header`, `action-group`, `table-wrapper`, and `data-table`; no page-local CSS added. | +| No inline styles, style blocks, inline handlers, or script blocks in HTML | PASS | Static PCRE check returned no matches for inline style/script/handler patterns. | +| No browser-owned authoritative product data | PASS | Messages, emotions, and reference counts load through the Local API; no localStorage/product-data fallback added. | +| No TTS implementation in this PR | PASS | Removed visible playback/TTS controls from Message Studio and did not add TTS behavior. Existing server TTS endpoints were not expanded. | +| No Emotion Profiles or Voice Profiles tools yet | PASS | Emotion/Voice remain visible reference columns/placeholders only; no profile management UI added. | +| Creator-safe empty/error states | PASS | Empty table and runtime failure messages avoid server diagnostics and stack details. | +| Required repo-structured ZIP under tmp/ | PASS | Prepared for `tmp/PR_26174_BRAVO_001-messages-table-structure_delta.zip`. | + +## Validation Lane Report + +| Lane | Result | Command / Notes | +| --- | --- | --- | +| Branch check | PASS | `git branch --show-current` -> `main`. | +| JS syntax | PASS | `node --check` on touched JS/MJS files. | +| Messages service contract | PASS | Inline Node check verified create, unreferenced delete, and referenced delete guard using `createMessagesPostgresClientStub`. | +| HTML restriction static check | PASS | `rg -n --pcre2 ']*\bsrc=)' toolbox/messages/index.html` returned no matches. | +| Diff whitespace | PASS | `git diff --check -- ` passed with line-ending warnings only. | +| Dependency install for validation | PASS_WITH_NOTE | `npm ci` completed; npm reported one existing high-severity audit finding, not changed in this PR. | +| Targeted Playwright Messages spec | BLOCKED | `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs` could not launch because Chromium was missing. `npx playwright install chromium` timed out twice (about 184s and 364s). A temporary system-Chrome config using `C:\Program Files\Google\Chrome\Application\chrome.exe` also timed out after about 184s, so browser assertions did not complete. | + +## Manual Validation Notes + +- Static source inspection confirms the Messages table headers are Message, Text, Tags, Emotion, Voice, Updated, Actions. +- Static source inspection confirms Add Message, Edit/Delete, Save/Cancel paths are present in the Messages controller. +- Static source inspection confirms old visible TTS Profile, Play Message, Stop Playback, and TTS selector hooks are absent from `toolbox/messages/index.html` and `toolbox/messages/messages.js`. +- Manual browser validation was not completed because the Playwright Chromium binary could not be installed and the fallback system-Chrome run timed out within the available command window. +- Unrelated pre-existing `.gitignore` modification was left untouched and excluded from this PR delta. + +## Changed Files + +- `src/dev-runtime/messages/messages-postgres-service.mjs` +- `tests/helpers/messagesPostgresClientStub.mjs` +- `tests/playwright/tools/MessagesTool.spec.mjs` +- `toolbox/messages/index.html` +- `toolbox/messages/messages-api-client.js` +- `toolbox/messages/messages.js` +- `docs_build/dev/reports/codex_review.diff` +- `docs_build/dev/reports/codex_changed_files.txt` +- `docs_build/dev/reports/PR_26174_BRAVO_001-messages-table-structure.md` + +## ZIP + +- `tmp/PR_26174_BRAVO_001-messages-table-structure_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_002-message-parts-child-table.md b/docs_build/dev/reports/PR_26174_BRAVO_002-message-parts-child-table.md new file mode 100644 index 000000000..8e761d4c1 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_002-message-parts-child-table.md @@ -0,0 +1,60 @@ +# PR_26174_BRAVO_002-message-parts-child-table + +## Summary + +Added expandable Message Parts child rows under each parent Message row. The child surface uses the existing Theme V2 table pattern with Order, Text, Emotion, Voice, and Actions columns, and supports Add Part, Edit, Save, Cancel, and Delete through the Local API. + +## Branch Validation + +| Check | Result | Notes | +| --- | --- | --- | +| Current branch is Bravo work branch | PASS | `git branch --show-current` returned `team/BRAVO/messages`. | +| Did not return to main | PASS | All PR_002 work was performed on `team/BRAVO/messages`. | + +## Requirement Checklist + +| Requirement | Result | Notes | +| --- | --- | --- | +| Add expandable child rows to Messages | PASS | Parent rows expose a Parts action that expands a child host row. | +| Parent row remains Message | PASS | The parent table remains Message-first with PR_001 columns. | +| Child rows are Message Parts | PASS | Expanded host renders Message Part rows from `messages_segments`. | +| Child columns Order, Text, Emotion, Voice, Actions | PASS | Child table renders exactly those headers. | +| Support Add Part, Edit, Save, Cancel, Delete | PASS | Inline child table actions are wired through external JS and Local API calls. | +| Reuse approved table-child-surface pattern | PASS | Reused `content-stack`, `surface-header`, `table-wrapper`, and `data-table data-table--fixed`. | +| Do not implement TTS playback yet | PASS | Voice remains a visible placeholder; no playback controls or speech calls were added. | +| No browser-owned authoritative product data | PASS | Message Parts are read/written/deleted through the Local API service. | +| Server/API owns authoritative key generation | PASS | Segment create still uses server-generated ULID keys. | +| Creator-safe errors only | PASS | UI save/delete failures use Creator-safe copy without server details. | +| Delete blocked when records are referenced | PASS | Parent message delete remains disabled when child parts exist; segment delete has no downstream references in this PR. | + +## Validation Lane Report + +| Lane | Result | Command / Notes | +| --- | --- | --- | +| JS syntax | PASS | `node --check toolbox/messages/messages.js; node --check toolbox/messages/messages-api-client.js; node --check src/dev-runtime/messages/messages-postgres-service.mjs; node --check tests/playwright/tools/MessagesTool.spec.mjs`. | +| Message Parts Local API contract | PASS | Inline Node check verified part create, parent delete block while referenced, part update, part delete, and parent delete unblock. | +| HTML restriction static check | PASS | `rg -n --pcre2 ']*\bsrc=)' toolbox/messages/index.html` returned no matches. | +| Diff whitespace | PASS | `git diff --check -- ` passed with line-ending warnings only. | +| Targeted Playwright | BLOCKED | Browser execution remains blocked because Playwright Chromium is missing in this workspace. | +| Fallback `npm run test:workspace-v2` | BLOCKED | The fallback lane also invokes Playwright and failed at browser launch with missing Chromium. | + +## Manual Validation Notes + +- Static inspection confirms no inline styles, style blocks, inline handlers, or HTML script blocks were added. +- Static inspection confirms PR_002 did not add TTS playback or provider runtime behavior. +- The parent delete guard now updates naturally after child part deletion because reference counts are reloaded from the Local API. +- Browser validation could not complete in this environment due missing Playwright Chromium. + +## Changed Files + +- `src/dev-runtime/messages/messages-postgres-service.mjs` +- `tests/playwright/tools/MessagesTool.spec.mjs` +- `toolbox/messages/messages-api-client.js` +- `toolbox/messages/messages.js` +- `docs_build/dev/reports/codex_review.diff` +- `docs_build/dev/reports/codex_changed_files.txt` +- `docs_build/dev/reports/PR_26174_BRAVO_002-message-parts-child-table.md` + +## ZIP + +- `tmp/PR_26174_BRAVO_002-message-parts-child-table_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_003-emotion-profiles-table.md b/docs_build/dev/reports/PR_26174_BRAVO_003-emotion-profiles-table.md new file mode 100644 index 000000000..d885a26a8 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_003-emotion-profiles-table.md @@ -0,0 +1,54 @@ +# PR_26174_BRAVO_003-emotion-profiles-table + +## Scope +- Added an Emotion Profiles reusable-asset table to Message Studio. +- Added inline Add/Edit/Save/Cancel behavior for emotion profiles through the Local API. +- Kept Messages and Message Parts referencing Emotion Profiles by profile key. +- Tightened emotion-profile rate/pitch/volume validation so invalid numeric inputs fail instead of falling back. + +## Branch Validation +| Check | Result | Notes | +| --- | --- | --- | +| Bravo branch retained | PASS | Current branch is `team/BRAVO/messages`. | +| Did not return to main | PASS | PR_003 was built as a stacked delta on the Bravo branch. | +| Did not merge or push main | PASS | No merge or push commands were run. | + +## Requirement Checklist +| Requirement | Result | Evidence | +| --- | --- | --- | +| Add Emotion Profiles as reusable assets | PASS | `toolbox/messages/index.html` adds the Emotion Profiles table and Add Emotion action; `toolbox/messages/messages.js` creates/updates profiles through the Local API. | +| Table columns: Emotion, Rate, Pitch, Volume, Updated, Actions | PASS | Emotion Profiles table headers match the required column list. | +| Seed/display Calm, Urgent, Whisper, Excited, Angry | PASS | Existing Local API seed data includes those required starter examples; the Playwright spec asserts their display. | +| Messages reference Emotion Profiles | PASS | Message rows continue to store/select `emotionProfileKey`; no rate/pitch/volume fields were added to messages. | +| Message Parts reference Emotion Profiles | PASS | Message Part rows continue to store/select `emotionProfileKey`; no rate/pitch/volume fields were added to parts. | +| Do not duplicate rate/pitch/volume directly on messages | PASS | Service contract validation asserts messages and parts do not expose `rate`, `pitch`, or `volume`. | +| Theme V2 and reusable table patterns | PASS | Uses `card`, `surface-header`, `table-wrapper`, `data-table`, `action-group`, and `btn`. | +| No inline styles, style blocks, inline handlers, or inline script blocks | PASS | Static scan found no disallowed inline HTML patterns in `toolbox/messages/index.html`. | +| No browser-owned authoritative product data | PASS | Profile creation and updates go through the Local API; keys remain server/API owned. | +| No TTS implementation in this PR | PASS | No playback/runtime/provider behavior was added. | +| No Emotion Profiles expansion beyond this PR purpose | PASS | Added only the requested profile table and profile references. | +| Creator-safe empty/error states | PASS | Empty table and failure messages avoid server detail exposure. | +| Delete blocked when referenced | PASS | No new direct delete path was introduced for emotion profiles; existing message delete guard remains intact. | + +## Validation Lane Report +| Lane | Result | Command / Evidence | +| --- | --- | --- | +| Syntax | PASS | `node --check toolbox/messages/messages.js; node --check src/dev-runtime/messages/messages-postgres-service.mjs; node --check tests/playwright/tools/MessagesTool.spec.mjs` | +| Inline HTML guard | PASS | `rg -n --pcre2 ']*\\bsrc=)' toolbox/messages/index.html` returned no matches. | +| Local API contract | PASS | Inline Node probe created/edited an emotion profile, rejected invalid numeric input, verified starter examples, and verified messages/parts only reference profile keys. | +| Diff hygiene | PASS | `git diff --check -- toolbox/messages/index.html toolbox/messages/messages.js src/dev-runtime/messages/messages-postgres-service.mjs tests/playwright/tools/MessagesTool.spec.mjs` reported line-ending warnings only. | +| Targeted Playwright | BLOCKED | `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --grep "Message Studio uses the approved table-first Messages structure" --project=playwright` failed because Chromium executable `chromium-1217` is not installed. | +| Fallback validation | BLOCKED | `npm run test:workspace-v2` failed for the same missing Playwright Chromium executable. | + +## Manual Validation Notes +- Browser validation could not complete because this workstation is missing the Playwright Chromium executable. +- The impacted UI path was covered by static DOM checks, focused spec updates, and a Local API service contract probe. +- Generated fallback lane side reports were restored; only PR_003-required report files remain in the PR delta. + +## Reports And Package +| Artifact | Path | +| --- | --- | +| Review diff | `docs_build/dev/reports/codex_review.diff` | +| Changed files | `docs_build/dev/reports/codex_changed_files.txt` | +| PR report | `docs_build/dev/reports/PR_26174_BRAVO_003-emotion-profiles-table.md` | +| Delta ZIP | `tmp/PR_26174_BRAVO_003-emotion-profiles-table_delta.zip` | diff --git a/docs_build/dev/reports/PR_26174_BRAVO_004-voice-profiles-table.md b/docs_build/dev/reports/PR_26174_BRAVO_004-voice-profiles-table.md new file mode 100644 index 000000000..7af2dec01 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_004-voice-profiles-table.md @@ -0,0 +1,55 @@ +# PR_26174_BRAVO_004-voice-profiles-table + +## Scope +- Added a Voice Profiles reusable-asset table to Message Studio. +- Seeded the required starter voice examples: Narrator, Hero, Merchant, Robot, Monster. +- Added server/API-owned `voiceProfileKey` references on Messages and Message Parts. +- Replaced visible Voice placeholders with profile selects while keeping playback/TTS runtime out of scope. + +## Branch Validation +| Check | Result | Notes | +| --- | --- | --- | +| Bravo branch retained | PASS | Current branch is `team/BRAVO/messages`. | +| Did not return to main | PASS | PR_004 was built as a stacked delta on the Bravo branch. | +| Did not merge or push main | PASS | No merge or push commands were run. | + +## Requirement Checklist +| Requirement | Result | Evidence | +| --- | --- | --- | +| Add Voice Profiles as reusable assets | PASS | `toolbox/messages/index.html` adds the Voice Profiles table; `toolbox/messages/messages.js` creates/updates profiles through the Local API. | +| Table columns: Voice, Provider, Voice Name, Language, Updated, Actions | PASS | Voice Profiles table headers match the required column list. | +| Starter examples: Narrator, Hero, Merchant, Robot, Monster | PASS | `SEED_TTS_PROFILES` now includes all five required starter voice profiles. | +| Messages reference Voice Profiles | PASS | Message rows now require and return `voiceProfileKey` / `voiceProfileName`. | +| Message Parts reference Voice Profiles | PASS | Message Part rows now require and return `voiceProfileKey` / `voiceProfileName`. | +| No browser-owned authoritative product data | PASS | Voice profiles and references are created through the Local API; keys remain server/API owned. | +| No browser-generated authoritative database keys | PASS | The service continues to generate ULID-style keys server-side. | +| No TTS implementation in this PR | PASS | No playback buttons, Browser Speech runtime, or provider runtime behavior was added. | +| Theme V2 and reusable table patterns | PASS | Uses `card`, `surface-header`, `table-wrapper`, `data-table`, `action-group`, and `btn`. | +| No inline styles, style blocks, inline handlers, or inline script blocks | PASS | Static scan found no disallowed inline HTML patterns in `toolbox/messages/index.html`. | +| Creator-safe empty/error states | PASS | UI failure messages remain generic and do not expose server details. | +| Use Local API / Local DB wording | PASS | The Messages page persistence label now uses Local DB wording. | +| Delete blocked when referenced | PASS | No new direct delete path was introduced for voice profiles; existing message delete guard remains intact. | + +## Validation Lane Report +| Lane | Result | Command / Evidence | +| --- | --- | --- | +| Syntax | PASS | `node --check toolbox/messages/messages.js; node --check src/dev-runtime/messages/messages-postgres-service.mjs; node --check tests/playwright/tools/MessagesTool.spec.mjs; node --check tests/dev-runtime/DbSeedIntegrity.test.mjs` | +| Inline HTML guard | PASS | `rg -n --pcre2 ']*\\bsrc=)' toolbox/messages/index.html` returned no matches. | +| Local API contract | PASS | Inline Node probe verified starter voice profiles, required voice references, response voice names, and default backfill for legacy rows. | +| Messages Local API seed subtest | PASS | `node --test --test-name-pattern "Messages Local API seeds" tests/dev-runtime/DbSeedIntegrity.test.mjs` | +| Diff hygiene | PASS | `git diff --check -- src/dev-runtime/messages/messages-postgres-service.mjs tests/dev-runtime/DbSeedIntegrity.test.mjs tests/playwright/tools/MessagesTool.spec.mjs toolbox/messages/index.html toolbox/messages/messages.js` reported line-ending warnings only. | +| Targeted Playwright | BLOCKED | `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --grep "Message Studio uses the approved table-first Messages structure" --project=playwright` failed because Chromium executable `chromium-1217` is not installed. | +| Fallback validation | BLOCKED | `npm run test:workspace-v2` failed for the same missing Playwright Chromium executable. | + +## Manual Validation Notes +- Browser validation could not complete because this workstation is missing the Playwright Chromium executable. +- The full `DbSeedIntegrity.test.mjs` file still has unrelated Local DB snapshot failures, but its Messages Local API subtest passes after the voice-profile changes. +- Generated fallback lane side reports were restored; only PR_004-required report files remain in the PR delta. + +## Reports And Package +| Artifact | Path | +| --- | --- | +| Review diff | `docs_build/dev/reports/codex_review.diff` | +| Changed files | `docs_build/dev/reports/codex_changed_files.txt` | +| PR report | `docs_build/dev/reports/PR_26174_BRAVO_004-voice-profiles-table.md` | +| Delta ZIP | `tmp/PR_26174_BRAVO_004-voice-profiles-table_delta.zip` | diff --git a/docs_build/dev/reports/PR_26174_BRAVO_005-message-reference-usage.md b/docs_build/dev/reports/PR_26174_BRAVO_005-message-reference-usage.md new file mode 100644 index 000000000..fce22de86 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_005-message-reference-usage.md @@ -0,0 +1,51 @@ +# PR_26174_BRAVO_005-message-reference-usage + +## Scope +- Added selected-message usage count display. +- Added a Reference Usage viewer that lists Message Part references for the selected Message. +- Added Archive/Restore actions for messages so referenced records can be archived instead of deleted. +- Kept delete blocked while a message has Message Part references. + +## Branch Validation +| Check | Result | Notes | +| --- | --- | --- | +| Bravo branch retained | PASS | Current branch is `team/BRAVO/messages`. | +| Did not return to main | PASS | PR_005 was built as a stacked delta on the Bravo branch. | +| Did not merge or push main | PASS | No merge or push commands were run. | + +## Requirement Checklist +| Requirement | Result | Evidence | +| --- | --- | --- | +| Add usage count display | PASS | Inspector now shows `data-messages-usage-count` for the selected Message. | +| Add reference viewer | PASS | Inspector now renders `data-messages-reference-list` with Message Part references and previews. | +| Block delete when referenced | PASS | Referenced message delete remains disabled in the UI and rejected by the Local API. | +| Prefer archive when referenced | PASS | Referenced messages expose an Archive action while Delete remains blocked. | +| No direct deletion if in use | PASS | Service contract verified delete rejection before and after archiving while a Message Part reference exists. | +| Theme V2 only | PASS | Added the viewer through existing `vertical-accordion` / `accordion-body` / `content-stack` patterns. | +| No inline styles, style blocks, inline handlers, or inline script blocks | PASS | Static scan found no disallowed inline HTML patterns in `toolbox/messages/index.html`. | +| No browser-owned authoritative product data | PASS | Archive/restore uses the Local API update path; no browser-owned product data was introduced. | +| Creator-safe errors | PASS | Archive failure text is generic and does not expose server details. | +| No TTS implementation | PASS | No playback/runtime/provider behavior was added. | + +## Validation Lane Report +| Lane | Result | Command / Evidence | +| --- | --- | --- | +| Syntax | PASS | `node --check toolbox/messages/messages.js; node --check tests/playwright/tools/MessagesTool.spec.mjs` | +| Inline HTML guard | PASS | `rg -n --pcre2 ']*\\bsrc=)' toolbox/messages/index.html` returned no matches. | +| Reference usage contract | PASS | Inline Node probe verified referenced delete rejection, archive success, continued delete block while referenced, and delete success after reference removal. | +| Diff hygiene | PASS | `git diff --check -- toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` reported line-ending warnings only. | +| Targeted Playwright | BLOCKED | `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --grep "Message Studio disables Delete when a message is referenced" --project=playwright` failed because Chromium executable `chromium-1217` is not installed. | +| Fallback validation | BLOCKED | `npm run test:workspace-v2` failed for the same missing Playwright Chromium executable. | + +## Manual Validation Notes +- Browser validation could not complete because this workstation is missing the Playwright Chromium executable. +- Static DOM checks and a Local API service contract probe covered the impacted reference/delete/archive behavior. +- Generated fallback lane side reports were restored; only PR_005-required report files remain in the PR delta. + +## Reports And Package +| Artifact | Path | +| --- | --- | +| Review diff | `docs_build/dev/reports/codex_review.diff` | +| Changed files | `docs_build/dev/reports/codex_changed_files.txt` | +| PR report | `docs_build/dev/reports/PR_26174_BRAVO_005-message-reference-usage.md` | +| Delta ZIP | `tmp/PR_26174_BRAVO_005-message-reference-usage_delta.zip` | diff --git a/docs_build/dev/reports/PR_26174_BRAVO_006-browser-tts-runtime.md b/docs_build/dev/reports/PR_26174_BRAVO_006-browser-tts-runtime.md new file mode 100644 index 000000000..66465dba7 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_006-browser-tts-runtime.md @@ -0,0 +1,49 @@ +# PR_26174_BRAVO_006-browser-tts-runtime + +## Scope +- Added Browser Speech API preview controls for the selected Message or Message Part. +- Implemented the runtime flow: Message -> Message Part -> Emotion Profile -> Voice Profile -> Browser Speech API. +- Added Stop Speech handling. +- Kept vendor provider integration out of scope. + +## Branch Validation +| Check | Result | Notes | +| --- | --- | --- | +| Bravo branch retained | PASS | Current branch is `team/BRAVO/messages`. | +| Did not return to main | PASS | PR_006 was built as a stacked delta on the Bravo branch. | +| Did not merge or push main | PASS | No merge or push commands were run. | + +## Requirement Checklist +| Requirement | Result | Evidence | +| --- | --- | --- | +| Implement Browser Speech API runtime | PASS | `toolbox/messages/messages.js` uses `window.speechSynthesis` and `SpeechSynthesisUtterance`. | +| Flow Message -> Message Part -> Emotion Profile -> Voice Profile -> Browser Speech API | PASS | Speech queue resolves selected part, or selected message parts in order, then applies emotion/voice profile settings to utterances. | +| Add test playback for selected message/part | PASS | Focused Playwright spec stubs `speechSynthesis` and asserts selected message and selected part utterance payloads. | +| No vendor provider integration yet | PASS | Runtime accepts only `browser-speech` provider and shows Creator-safe failure for any other provider. | +| No silent provider fallback | PASS | Missing Browser Speech API, missing profile, unavailable provider, or unavailable named voice fails with Creator-safe UI text. | +| Theme V2 only | PASS | Speech controls use existing `vertical-accordion`, `accordion-body`, `content-stack`, `action-group`, and `btn` classes. | +| No inline styles, style blocks, inline handlers, or inline script blocks | PASS | Static scan found no disallowed inline HTML patterns in `toolbox/messages/index.html`. | +| No browser-owned authoritative product data | PASS | Speech preview reads existing Local API-backed message/profile data and persists nothing. | +| Creator-safe runtime failure text | PASS | Speech failures use generic user-facing messages and do not expose browser exception details. | + +## Validation Lane Report +| Lane | Result | Command / Evidence | +| --- | --- | --- | +| Syntax | PASS | `node --check toolbox/messages/messages.js; node --check tests/playwright/tools/MessagesTool.spec.mjs` | +| Inline HTML guard | PASS | `rg -n --pcre2 ']*\\bsrc=)' toolbox/messages/index.html` returned no matches. | +| Diff hygiene | PASS | `git diff --check -- toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` reported line-ending warnings only. | +| Targeted Playwright | BLOCKED | `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --grep "Message Studio uses the approved table-first Messages structure" --project=playwright` failed because Chromium executable `chromium-1217` is not installed. | +| Fallback validation | BLOCKED | `npm run test:workspace-v2` failed for the same missing Playwright Chromium executable. | + +## Manual Validation Notes +- Browser validation could not complete because this workstation is missing the Playwright Chromium executable. +- Runtime behavior is covered in the focused Playwright spec with a deterministic `speechSynthesis` stub, ready to execute once Chromium is installed. +- Generated fallback lane side reports were restored; only PR_006-required report files remain in the PR delta. + +## Reports And Package +| Artifact | Path | +| --- | --- | +| Review diff | `docs_build/dev/reports/codex_review.diff` | +| Changed files | `docs_build/dev/reports/codex_changed_files.txt` | +| PR report | `docs_build/dev/reports/PR_26174_BRAVO_006-browser-tts-runtime.md` | +| Delta ZIP | `tmp/PR_26174_BRAVO_006-browser-tts-runtime_delta.zip` | diff --git a/docs_build/dev/reports/PR_26174_BRAVO_007-tts-provider-framework.md b/docs_build/dev/reports/PR_26174_BRAVO_007-tts-provider-framework.md new file mode 100644 index 000000000..fb9edbcde --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_007-tts-provider-framework.md @@ -0,0 +1,49 @@ +# PR_26174_BRAVO_007-tts-provider-framework + +## Scope +- Added an approved TTS provider framework. +- Providers: Browser Speech API, ElevenLabs, OpenAI, Azure, Polly. +- Kept Browser Speech API as the only active runtime provider. +- Added explicit provider validation and Creator-safe runtime failure behavior for unavailable providers. + +## Branch Validation +| Check | Result | Notes | +| --- | --- | --- | +| Bravo branch retained | PASS | Current branch is `team/BRAVO/messages`. | +| Did not return to main | PASS | PR_007 was built as a stacked delta on the Bravo branch. | +| Did not merge or push main | PASS | No merge or push commands were run. | + +## Requirement Checklist +| Requirement | Result | Evidence | +| --- | --- | --- | +| Add provider abstraction | PASS | `toolbox/messages/messages.js` defines `TTS_PROVIDER_REGISTRY`; service validates approved provider keys. | +| Providers include Browser Speech API, ElevenLabs, OpenAI, Azure, Polly | PASS | Provider registry and Voice Profile select expose all five providers. | +| Browser Speech API remains only active runtime provider | PASS | Runtime gate allows only `browser-speech`; vendor providers remain inactive placeholders. | +| No silent provider fallback | PASS | Unknown, inactive, or config-required providers fail instead of falling back. | +| Invalid/missing provider config Creator-safe errors | PASS | UI catches provider runtime failures with generic speech-preview messages; service rejects unsupported provider keys. | +| No vendor provider integration | PASS | No vendor network calls, credentials, SDKs, or provider runtime implementations were added. | +| No browser-owned authoritative product data | PASS | Provider choices are saved through Local API voice profiles only. | +| Theme V2 / no inline HTML violations | PASS | This PR does not add page-local CSS or inline HTML handlers/styles/scripts. | + +## Validation Lane Report +| Lane | Result | Command / Evidence | +| --- | --- | --- | +| Syntax | PASS | `node --check toolbox/messages/messages.js; node --check src/dev-runtime/messages/messages-postgres-service.mjs; node --check tests/playwright/tools/MessagesTool.spec.mjs` | +| Inline HTML guard | PASS | `rg -n --pcre2 ']*\\bsrc=)' toolbox/messages/index.html` returned no matches. | +| Provider contract | PASS | Inline Node probe verified approved provider persistence and unsupported provider rejection. | +| Diff hygiene | PASS | `git diff --check -- src/dev-runtime/messages/messages-postgres-service.mjs toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` reported line-ending warnings only. | +| Targeted Playwright | BLOCKED | `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --grep "Message Studio uses the approved table-first Messages structure" --project=playwright` failed because Chromium executable `chromium-1217` is not installed. | +| Fallback validation | BLOCKED | `npm run test:workspace-v2` failed for the same missing Playwright Chromium executable. | + +## Manual Validation Notes +- Browser validation could not complete because this workstation is missing the Playwright Chromium executable. +- Focused spec now asserts the approved provider options and Browser Speech API provider label. +- Generated fallback lane side reports were restored; only PR_007-required report files remain in the PR delta. + +## Reports And Package +| Artifact | Path | +| --- | --- | +| Review diff | `docs_build/dev/reports/codex_review.diff` | +| Changed files | `docs_build/dev/reports/codex_changed_files.txt` | +| PR report | `docs_build/dev/reports/PR_26174_BRAVO_007-tts-provider-framework.md` | +| Delta ZIP | `tmp/PR_26174_BRAVO_007-tts-provider-framework_delta.zip` | diff --git a/docs_build/dev/reports/PR_26174_BRAVO_008-message-event-actions.md b/docs_build/dev/reports/PR_26174_BRAVO_008-message-event-actions.md new file mode 100644 index 000000000..eb68e18e3 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_008-message-event-actions.md @@ -0,0 +1,50 @@ +# PR_26174_BRAVO_008-message-event-actions + +## Scope +- Added server-owned Message Event Action records through the Local API. +- Added Events page support for Show Message, Speak Message, and Wait For Continue. +- Added message references from event actions to Messages. +- Avoided browser-owned event data; the Events page only renders/saves Local API records. + +## Branch Validation +| Check | Result | Notes | +| --- | --- | --- | +| Bravo branch retained | PASS | Current branch is `team/BRAVO/messages`. | +| Did not return to main | PASS | PR_008 was built as a stacked delta on the Bravo branch. | +| Did not merge or push main | PASS | No merge or push commands were run. | + +## Requirement Checklist +| Requirement | Result | Evidence | +| --- | --- | --- | +| Add Show Message option | PASS | Event action option `show-message` is supported by service and Events UI. | +| Add Speak Message option | PASS | Event action option `speak-message` is supported by service and Events UI. | +| Add Wait For Continue option | PASS | Event action option `wait-for-continue` is supported by service and Events UI. | +| Support references from Events to Messages | PASS | `messages_event_actions.messageKey` references Messages for Show/Speak actions. | +| Do not create browser-owned event data | PASS | Events page loads and saves action rows through `/api/messages/event-actions`; no local browser event records are persisted. | +| Creator-safe errors | PASS | Events UI uses generic Local API failure messages. | +| No inline styles, style blocks, inline handlers, or inline script blocks | PASS | Static scan found no disallowed inline HTML patterns in `toolbox/events/index.html`. | +| Theme V2 only | PASS | Events UI uses existing Theme V2 cards, table, accordion, button, and action-group classes. | +| No TTS provider expansion | PASS | PR only stores event action references; it does not add vendor/runtime provider behavior. | + +## Validation Lane Report +| Lane | Result | Command / Evidence | +| --- | --- | --- | +| Syntax | PASS | `node --check src/dev-runtime/messages/messages-postgres-service.mjs; node --check toolbox/events/events.js; node --check tests/playwright/tools/EventsTool.spec.mjs` | +| Inline HTML guard | PASS | `rg -n --pcre2 ']*\\bsrc=)' toolbox/events/index.html` returned no matches. | +| Event action contract | PASS | Inline Node probe verified Show/Speak message requirements, Wait For Continue no-message rule, unsupported action rejection, and message reference output. | +| Diff hygiene | PASS | `git diff --check -- src/dev-runtime/messages/messages-postgres-service.mjs toolbox/events/index.html toolbox/events/events.js tests/playwright/tools/EventsTool.spec.mjs` reported line-ending warnings only. | +| Targeted Playwright | BLOCKED | `npx playwright test tests/playwright/tools/EventsTool.spec.mjs --project=playwright` failed because Chromium executable `chromium-1217` is not installed. | +| Fallback validation | BLOCKED | `npm run test:workspace-v2` failed for the same missing Playwright Chromium executable. | + +## Manual Validation Notes +- Browser validation could not complete because this workstation is missing the Playwright Chromium executable. +- Focused Events spec was added to cover the Local API-backed UI path once Chromium is available. +- Generated fallback lane side reports were restored; only PR_008-required report files remain in the PR delta. + +## Reports And Package +| Artifact | Path | +| --- | --- | +| Review diff | `docs_build/dev/reports/codex_review.diff` | +| Changed files | `docs_build/dev/reports/codex_changed_files.txt` | +| PR report | `docs_build/dev/reports/PR_26174_BRAVO_008-message-event-actions.md` | +| Delta ZIP | `tmp/PR_26174_BRAVO_008-message-event-actions_delta.zip` | diff --git a/docs_build/dev/reports/PR_26174_BRAVO_009-message-publish-validation.md b/docs_build/dev/reports/PR_26174_BRAVO_009-message-publish-validation.md new file mode 100644 index 000000000..dbab4647b --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_009-message-publish-validation.md @@ -0,0 +1,55 @@ +# PR_26174_BRAVO_009-message-publish-validation + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Work continued on the stacked Bravo branch. +- PASS: No merge was performed. +- PASS: No push to `main` was performed. +- PASS: `start_of_day` folders were not modified. + +## Scope Summary +- Added server-owned Messages Local API publish validation at `/api/messages/publish-validation`. +- Added a Message Studio Publish Validation inspector panel with external JavaScript behavior. +- Added focused dev-runtime coverage for publish-ready and publish-blocked message/TTS configurations. +- Updated the Messages Playwright spec to cover the visible validation panel and successful validation flow. + +## Requirement Checklist +- PASS: Missing message text is reported for Messages and Message Parts. +- PASS: Missing Emotion Profile is reported. +- PASS: Missing Voice Profile is reported. +- PASS: Broken Message, Emotion Profile, Voice Profile, and Event Action references are reported. +- PASS: Invalid provider assignment is reported; Browser Speech API is the only publish-ready provider. +- PASS: Publish validation returns `canPublish: false` and `valid: false` for invalid message/TTS configuration. +- PASS: Theme V2 classes and existing external JavaScript patterns are used. +- PASS: No inline styles, style blocks, inline handlers, or inline script blocks were added. +- PASS: No browser-owned authoritative product data was added. +- PASS: No browser-generated authoritative database keys were added. +- PASS: Local API / Local DB wording is preserved. +- PASS: API/runtime failure text is Creator-safe. + +## Validation Lane Report +- PASS: `node --check src\dev-runtime\messages\messages-postgres-service.mjs` +- PASS: `node --check toolbox\messages\messages-api-client.js` +- PASS: `node --check toolbox\messages\messages.js` +- PASS: `node --check tests\dev-runtime\MessagesPublishValidation.test.mjs` +- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs` +- PASS: HTML inline/style/script guard on `toolbox/messages/index.html` +- PASS: Ownership guard for `imageDataUrl`, browser-owned wording, `localStorage`, and `sessionStorage` +- PASS: `node --test tests\dev-runtime\MessagesPublishValidation.test.mjs` +- PASS: `git diff --check` for PR_009 touched files, with line-ending warnings only +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` + - Blocker: Playwright Chromium executable is not installed in the local `ms-playwright` cache. +- BLOCKED: `npm run test:workspace-v2` + - Blocker: Same missing Playwright Chromium executable. + +## Manual Validation Notes +- Verified the Messages tool has a visible Publish Validation panel with `Run Validation`, status, and issue list. +- Verified validation result rendering supports Not checked, Ready, and Blocked states. +- Verified validation failures are Creator-safe and do not expose server or database internals. +- Browser visual/manual validation still needs a local Playwright browser install before the Playwright lane can launch. + +## Reports And Package +- Shared diff: `docs_build/dev/reports/codex_review.diff` +- Changed files: `docs_build/dev/reports/codex_changed_files.txt` +- PR report: `docs_build/dev/reports/PR_26174_BRAVO_009-message-publish-validation.md` +- Delta ZIP: `tmp/PR_26174_BRAVO_009-message-publish-validation_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_010-separate-messages-and-tts-ownership.md b/docs_build/dev/reports/PR_26174_BRAVO_010-separate-messages-and-tts-ownership.md new file mode 100644 index 000000000..c4b6d9bfa --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_010-separate-messages-and-tts-ownership.md @@ -0,0 +1,63 @@ +# PR_26174_BRAVO_010-separate-messages-and-tts-ownership + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Work continued on the stacked Bravo branch. +- PASS: No merge was performed. +- PASS: No return to `main` was performed. +- PASS: No push to `main` was performed. +- PASS: `start_of_day` folders were not modified. + +## Scope Summary +- Separated visible ownership between Message Studio and Text To Speech. +- Message Studio now owns Messages and ordered sentence rows only. +- Text To Speech now owns TTS Profile rows and child emotion audio settings. +- Message Studio playback resolves through TTS Profile and sentence emotion settings. + +## Requirement Checklist +- PASS: Messages no longer shows Reusable Assets / Emotion Profiles sections. +- PASS: Messages no longer shows Voice Profiles sections. +- PASS: Messages parent table columns are Message, TTS Profile, Updated, Actions. +- PASS: Messages parent row Actions include Play at the end. +- PASS: Messages child table is sentences with Order, Text, Emotion, Actions. +- PASS: Messages references TTS Profile by key/name. +- PASS: Sentences reference Emotion by key/name from the selected TTS Profile option contract. +- PASS: Messages does not expose pitch, rate, volume, voice, or provider authoring controls. +- PASS: Text To Speech parent table columns are Profile, Gender, Voice, Language, Age Filter, Emotion Count, Status, Actions. +- PASS: Text To Speech child emotion table columns are Emotion, Pitch, Rate, Volume, Actions. +- PASS: Pitch, Rate, and Volume editors are range sliders. +- PASS: Emotion editor remains a dropdown. +- PASS: Browser Speech runtime remains connected through TTS Profile plus Emotion. +- PASS: Theme V2/external JavaScript pattern preserved. +- PASS: No inline styles, style blocks, inline handlers, or inline script blocks were added. +- PASS: No browser-owned authoritative product data or browser-generated authoritative database keys were added. +- PASS: Creator-safe failure text is preserved. + +## Validation Lane Report +- PASS: `node --check src\dev-runtime\messages\messages-postgres-service.mjs` +- PASS: `node --check toolbox\messages\messages.js` +- PASS: `node --check toolbox\text-to-speech\text2speech.js` +- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs` +- PASS: `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs` +- PASS: `node --check tests\tools\Text2SpeechShell.test.mjs` +- PASS: `node --test tests\tools\Text2SpeechShell.test.mjs` +- PASS: `node --test tests\dev-runtime\MessagesPublishValidation.test.mjs` +- PASS: HTML inline/style/script guard on `toolbox/messages/index.html` and `toolbox/text-to-speech/index.html` +- PASS: Ownership guard for `imageDataUrl`, `localStorage`, and `sessionStorage` +- PASS: `git diff --check` for PR_010 touched files, with line-ending warnings only +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright` + - Blocker: Playwright Chromium executable is not installed in the local `ms-playwright` cache. +- BLOCKED: `npm run test:workspace-v2` + - Blocker: Same missing Playwright Chromium executable. + +## Manual Validation Notes +- Reviewed Message Studio HTML/JS to confirm old profile cards are no longer rendered. +- Reviewed Text To Speech HTML/JS to confirm profile parent columns and slider-based emotion child rows. +- Reviewed Messages playback path: Message -> TTS Profile -> Sentence -> Emotion setting -> Browser Speech API. +- Browser visual validation still needs a local Playwright browser install before the Playwright lane can launch. + +## Reports And Package +- Shared diff: `docs_build/dev/reports/codex_review.diff` +- Changed files: `docs_build/dev/reports/codex_changed_files.txt` +- PR report: `docs_build/dev/reports/PR_26174_BRAVO_010-separate-messages-and-tts-ownership.md` +- Delta ZIP: `tmp/PR_26174_BRAVO_010-separate-messages-and-tts-ownership_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_011-message-sentence-action-buttons.md b/docs_build/dev/reports/PR_26174_BRAVO_011-message-sentence-action-buttons.md new file mode 100644 index 000000000..5a8c2768f --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_011-message-sentence-action-buttons.md @@ -0,0 +1,49 @@ +# PR_26174_BRAVO_011-message-sentence-action-buttons + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the Bravo branch without returning to `main`. +- PASS: No merge was performed. +- PASS: No `start_of_day` folders were modified. + +## Requirement Checklist +- PASS: Parent table remains `Game Text Repository` Messages. +- PASS: Parent Message table keeps columns `Message`, `TTS Profile`, `Updated`, `Actions`. +- PASS: Parent controls include Add Message, Edit Message, Save Message, Cancel Message, Delete/Archive Message, and Play Message. +- PASS: Parent Message rows expand to child Sentence rows. +- PASS: Child Sentence table keeps columns `Order`, `Text`, `Emotion`, `Actions`. +- PASS: Child controls include Add Sentence, Edit Sentence, Save Sentence, Cancel Sentence, Delete/Archive Sentence, and Play Sentence. +- PASS: Creator-facing Messages UI now uses Sentence/Sentences wording for the child records. +- PASS: Existing internal `segment` and `parts` identifiers are retained only where changing them would expand risk. +- PASS: Message Play queues ordered Sentences from the selected Message. +- PASS: Sentence Play uses the same Browser Speech runtime path for only the selected Sentence. +- PASS: Edit actions switch rows to Save/Cancel controls. +- PASS: Cancel leaves the previous row state intact. +- PASS: Message Delete remains blocked while the Message has Sentences, with Archive available. +- PASS: Sentence Archive/Restore is available as the preferred non-destructive action. +- PASS: Messages page does not show `Reusable Assets Emotion Profiles`, `Emotion Profiles`, or `Voice Profiles` sections. +- PASS: Messages page does not add pitch, rate, volume, voice, or provider ownership controls. +- PASS: No inline styles, style blocks, inline handlers, or HTML script blocks were added. +- PASS: No browser-owned authoritative product data was added. +- PASS: Errors added or changed are Creator-safe. + +## Validation Lane Report +- PASS: `node --check toolbox/messages/messages.js` +- PASS: `node --check tests/playwright/tools/MessagesTool.spec.mjs` +- PASS: `rg -n --pcre2 "]*\\bsrc=)" toolbox/messages/index.html` returned no matches. +- PASS: `rg -n "localStorage|sessionStorage|imageDataUrl|Reusable Assets|Emotion Profiles|Voice Profiles|Message Parts" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` found only negative Playwright assertions for forbidden labels. +- PASS: `git diff --check -- toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` completed with no whitespace errors. Git emitted existing CRLF normalization warnings. +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: `npm run test:workspace-v2` reached the same missing Chromium blocker after its non-browser audits completed. + +## Manual Validation Notes +- Reviewed `toolbox/messages/index.html` and confirmed the Inspector label now says `Sentence`, not `Part`. +- Reviewed `toolbox/messages/messages.js` and confirmed Sentence rows render Edit, Archive/Restore, Delete, and Play actions. +- Reviewed playback flow and confirmed Message Play maps ordered Sentences through `speechItemFromSegment`, while Sentence Play queues only the selected Sentence. +- Reviewed Messages ownership and confirmed no profile management tables or TTS setting sliders were added to Messages. +- Browser click-through validation is pending a local Playwright Chromium install. + +## Review Artifacts +- Review diff: `docs_build/dev/reports/codex_review.diff` +- Changed files: `docs_build/dev/reports/codex_changed_files.txt` +- Delta ZIP: `tmp/PR_26174_BRAVO_011-message-sentence-action-buttons_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_012-tts-preview-action-cleanup.md b/docs_build/dev/reports/PR_26174_BRAVO_012-tts-preview-action-cleanup.md new file mode 100644 index 000000000..fea9e968c --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_012-tts-preview-action-cleanup.md @@ -0,0 +1,45 @@ +# PR_26174_BRAVO_012-tts-preview-action-cleanup + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the Bravo branch without returning to `main`. +- PASS: No merge was performed. +- PASS: No `start_of_day` folders were modified. + +## Requirement Checklist +- PASS: Updated only the Text To Speech page, related external JS, targeted tests, and required reports. +- PASS: Removed creator-visible `TTS Profile / Emotion Settings` child-table text. +- PASS: Removed creator-visible `Emotion Settings` heading/text from the Text To Speech surface. +- PASS: Kept Text To Speech ownership of TTS profile parent rows and Emotion child rows. +- PASS: Kept Pitch, Rate, and Volume as child Emotion row sliders in edit mode. +- PASS: Child Emotion table columns remain `Emotion`, `Pitch`, `Rate`, `Volume`, `Actions`. +- PASS: Added a Play button under Actions for each child Emotion row. +- PASS: Emotion-row Play speaks the Local Preview `Text To Speak` text. +- PASS: Emotion-row Play applies that row's Pitch, Rate, and Volume values. +- PASS: Local Preview area remains `Speech Composition` and `Text To Speak`. +- PASS: No inline styles, inline scripts, inline handlers, or page-local CSS were added. +- PASS: The implementation uses external JavaScript and Theme V2 classes already present on the page. +- PASS: Browser Speech unavailable path shows a Creator-safe message and does not silently fallback. + +## Validation Lane Report +- PASS: `node --check toolbox/text-to-speech/text2speech.js` +- PASS: `node --check tests/playwright/tools/TextToSpeechFunctional.spec.mjs` +- PASS: `rg -n --pcre2 "]*\\bsrc=)" toolbox/text-to-speech/index.html` returned no matches. +- PASS: `rg -n "TTS Profile / Emotion Settings|Emotion Settings|Emotion setting|Emotion Setting|Message Parts" toolbox/text-to-speech/index.html toolbox/text-to-speech/text2speech.js tests/playwright/tools/TextToSpeechFunctional.spec.mjs` found only negative Playwright assertions. +- PASS: `git diff --check -- toolbox/text-to-speech/index.html toolbox/text-to-speech/text2speech.js tests/playwright/tools/TextToSpeechFunctional.spec.mjs` completed with no whitespace errors. Git emitted existing CRLF normalization warnings. +- PASS: `node --test tests/tools/Text2SpeechShell.test.mjs` +- BLOCKED: `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: `npm run test:workspace-v2` reached the same missing Chromium blocker after its non-browser audits completed. + +## Manual Validation Notes +- Reviewed `toolbox/text-to-speech/index.html` and confirmed Local Preview still shows `Speech Composition` and `Text To Speak`. +- Reviewed `toolbox/text-to-speech/text2speech.js` and confirmed the old child table heading/kicker nodes were removed. +- Reviewed the child Emotion row rendering and confirmed each row now includes Edit Emotion, Delete, and Play actions. +- Reviewed playback flow and confirmed row Play uses the selected profile, selected emotion row, Local Preview text, and row Pitch/Rate/Volume values. +- Reviewed unavailable browser speech flow and confirmed row Play writes the Creator-safe Web Speech API support message without fallback behavior. +- Browser click-through validation is pending a local Playwright Chromium install. + +## Review Artifacts +- Review diff: `docs_build/dev/reports/codex_review.diff` +- Changed files: `docs_build/dev/reports/codex_changed_files.txt` +- Delta ZIP: `tmp/PR_26174_BRAVO_012-tts-preview-action-cleanup_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_013-message-and-sentence-play-buttons.md b/docs_build/dev/reports/PR_26174_BRAVO_013-message-and-sentence-play-buttons.md new file mode 100644 index 000000000..14235b264 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_013-message-and-sentence-play-buttons.md @@ -0,0 +1,51 @@ +# PR_26174_BRAVO_013-message-and-sentence-play-buttons + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the Bravo branch without returning to `main`. +- PASS: No merge was performed. +- PASS: No `start_of_day` folders were modified. + +## Requirement Checklist +- PASS: Parent table record remains Message. +- PASS: Child table record remains Sentence. +- PASS: Parent Message rows have a Play button in Actions. +- PASS: Parent Play queues all child Sentences in display order. +- PASS: Child Sentence rows have a Play button in Actions. +- PASS: Child Play queues only the selected Sentence. +- PASS: Message playback resolves through Message, selected TTS Profile, ordered Sentences, Sentence Emotion, and TTS Profile emotion settings. +- PASS: Sentence playback resolves through Sentence, parent Message TTS Profile, Sentence Emotion, and TTS Profile emotion settings. +- PASS: Missing Sentences, TTS Profile, browser voice, or TTS Profile emotion settings produce Creator-safe guidance. +- PASS: Removed silent fallback from Message text when a Message has no Sentences. +- PASS: Removed fallback from Message-owned/global emotion profiles during playback; playback now requires the selected TTS Profile emotion setting. +- PASS: Messages does not gain voice, emotion slider, pitch, rate, or volume ownership. +- PASS: Messages owns what to speak and Text To Speech owns how to speak. +- PASS: Text To Speech Profile add/edit refreshes and displays available browser voices in the Voice dropdown. +- PASS: Voice selection is preserved through profile edit and used by Emotion-row Play. +- PASS: Emotion-row Play uses selected profile voice plus row Pitch, Rate, and Volume. +- PASS: Local Preview `Text To Speak` remains the preview text source. + +## Validation Lane Report +- PASS: `node --check toolbox/messages/messages.js` +- PASS: `node --check toolbox/text-to-speech/text2speech.js` +- PASS: `node --check tests/playwright/tools/MessagesTool.spec.mjs` +- PASS: `node --check tests/playwright/tools/TextToSpeechFunctional.spec.mjs` +- PASS: `rg -n --pcre2 "]*\\bsrc=)" toolbox/messages/index.html toolbox/text-to-speech/index.html` returned no matches. +- PASS: `git diff --check -- toolbox/messages/messages.js toolbox/text-to-speech/text2speech.js tests/playwright/tools/MessagesTool.spec.mjs tests/playwright/tools/TextToSpeechFunctional.spec.mjs` completed with no whitespace errors. Git emitted existing CRLF normalization warnings. +- PASS: `node --test tests/tools/Text2SpeechShell.test.mjs` +- PASS: `rg --pcre2 -n "localStorage|sessionStorage|imageDataUrl|style=|]*src=)" toolbox/messages toolbox/text-to-speech tests/playwright/tools/MessagesTool.spec.mjs tests/playwright/tools/TextToSpeechFunctional.spec.mjs` returned no matches. +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: `npm run test:workspace-v2` reached the same missing Chromium blocker after its non-browser audits completed. + +## Manual Validation Notes +- Reviewed Messages playback code and confirmed parent Play blocks when no child Sentences exist. +- Reviewed Messages playback code and confirmed Sentence Play uses the parent Message TTS Profile, not Sentence-owned voice settings. +- Reviewed Messages playback code and confirmed browser voice lookup requires an available selected voice instead of silently using browser defaults. +- Reviewed Text To Speech profile editor code and confirmed add/edit refreshes browser voices before rendering the Voice dropdown. +- Reviewed Text To Speech Emotion-row Play and confirmed it sends selected voice, Local Preview text, and row Pitch/Rate/Volume to the speech engine. +- Browser click-through validation is pending a local Playwright Chromium install. + +## Review Artifacts +- Review diff: `docs_build/dev/reports/codex_review.diff` +- Changed files: `docs_build/dev/reports/codex_changed_files.txt` +- Delta ZIP: `tmp/PR_26174_BRAVO_013-message-and-sentence-play-buttons_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_014-message-play-button-regression-fix.md b/docs_build/dev/reports/PR_26174_BRAVO_014-message-play-button-regression-fix.md new file mode 100644 index 000000000..ec7f9610d --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_014-message-play-button-regression-fix.md @@ -0,0 +1,45 @@ +# PR_26174_BRAVO_014-message-play-button-regression-fix + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the Bravo branch without returning to `main`. +- PASS: No merge was performed. +- PASS: No `start_of_day` folders were modified. + +## Requirement Checklist +- PASS: Parent table record remains Message. +- PASS: Child table record remains Sentence. +- PASS: Parent Message row Actions always render Play, Edit, Archive/Restore, and Delete. +- PASS: Parent Play remains visible after parent Archive/Restore state changes. +- PASS: Parent Play remains visible after child Sentence Delete state changes. +- PASS: Parent Play speaks ordered child Sentences. +- PASS: Child Sentence row Actions render Play, Edit, Archive/Restore, and Delete. +- PASS: Child Play speaks only that Sentence. +- PASS: Delete/Archive actions do not remove unrelated Play actions. +- PASS: Creator-facing UI continues to use Sentence/Sentences, not Message Parts. +- PASS: Messages owns what to speak; no TTS sliders or voice/provider ownership were moved into Messages. +- PASS: Playback continues to resolve through selected TTS Profile and selected Sentence Emotion. +- PASS: Existing Creator-safe guidance remains for missing profile, voice, emotion, sentence text, or Sentences. +- PASS: No silent fallback was added. +- PASS: No inline styles, inline scripts, inline handlers, or page-local CSS were added. + +## Validation Lane Report +- PASS: `node --check toolbox/messages/messages.js` +- PASS: `node --check tests/playwright/tools/MessagesTool.spec.mjs` +- PASS: `rg --pcre2 -n "]*\\bsrc=)|Message Parts" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` found only the negative Playwright assertion for `Message Parts`. +- PASS: `rg --pcre2 -n "localStorage|sessionStorage|imageDataUrl" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` returned no matches. +- PASS: `git diff --check -- toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` completed with no whitespace errors. Git emitted existing CRLF normalization warnings. +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: `npm run test:workspace-v2` reached the same missing Chromium blocker after its non-browser audits completed. + +## Manual Validation Notes +- Reviewed parent Message row rendering and confirmed Play is rendered first in the Actions group, followed by Sentences, Edit, Archive/Restore, and Delete. +- Reviewed child Sentence row rendering and confirmed Play is rendered first in the Actions group, followed by Edit, Archive/Restore, and Delete. +- Reviewed Messages playback code and confirmed parent Play queues ordered Sentences while child Play queues one selected Sentence. +- Reviewed Playwright coverage additions for child Play one-row speech, parent Play ordered speech, parent Archive/Restore visibility, and child Delete/Archive visibility preservation. +- Browser click-through validation is pending a local Playwright Chromium install. + +## Review Artifacts +- Review diff: `docs_build/dev/reports/codex_review.diff` +- Changed files: `docs_build/dev/reports/codex_changed_files.txt` +- Delta ZIP: `tmp/PR_26174_BRAVO_014-message-play-button-regression-fix_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_015-child-play-uses-parent-tts-profile.md b/docs_build/dev/reports/PR_26174_BRAVO_015-child-play-uses-parent-tts-profile.md new file mode 100644 index 000000000..e3b8dc4bc --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_015-child-play-uses-parent-tts-profile.md @@ -0,0 +1,46 @@ +# PR_26174_BRAVO_015-child-play-uses-parent-tts-profile + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the existing Bravo branch; no merge and no return to main. + +## Purpose +- PASS: Scoped to Messages playback resolving child Sentence Play through the parent Message TTS Profile. + +## Requirement Checklist +- PASS: Parent Message owns the selected TTS Profile; playback items resolve `message.voiceProfileKey` before speaking. +- PASS: Child Sentence owns only Text and Emotion in the Messages UI; voice/profile settings remain outside sentence rows. +- PASS: Child Sentence Play resolves Sentence -> Parent Message -> TTS Profile -> Profile Voice -> Sentence Emotion -> Profile Emotion Settings. +- PASS: Parent Message Play resolves Message -> TTS Profile -> ordered Sentences -> Profile Voice and each Sentence Emotion. +- PASS: Messages playback does not use Local Preview voice selection or the Text To Speech preview workflow. +- PASS: The old Messages playback failure `Select an available browser voice before preview.` is not present in `toolbox/messages` and is covered by negative Playwright assertions. +- PASS: Missing TTS Profile, missing profile voice, missing sentence emotion, and profile/emotion mismatch use Creator-safe guidance. +- PASS: Text To Speech remains the owner of profile voice and emotion slider settings. +- PASS: No inline styles, inline scripts, inline handlers, style blocks, or page-local CSS were added. +- PASS: No browser-owned authoritative product data or browser-generated authoritative database keys were added. + +## Validation Lane Report +- PASS: `node --check toolbox/messages/messages.js` +- PASS: `node --check tests/playwright/tools/MessagesTool.spec.mjs` +- PASS: `rg --pcre2 -n "]*\\bsrc=)" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` returned no matches. +- PASS: `rg --pcre2 -n "localStorage|sessionStorage|imageDataUrl" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` returned no matches. +- PASS: `rg -n "Select an available browser voice before preview|Local Preview|Text To Speak" toolbox/messages` returned no matches. +- PASS: `git diff --check -- toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: Fallback `npm run test:workspace-v2` reached the same missing Chromium blocker after completing the static/preflight report generation. + +## Manual Validation Notes +- Reviewed `speechItemFromSegment`: child Sentence Play now derives the profile key from the parent Message and does not use any sentence/local preview voice value. +- Reviewed `speakQueue`: profile voice and profile emotion settings are applied before `speechSynthesis.speak`. +- Updated Playwright coverage to select the Hero TTS Profile, assert child Sentence Play speaks only one sentence with `Browser hero`, assert parent Message Play speaks all ordered sentences with `Browser hero`, and assert the old preview failure does not appear from Messages playback. +- Browser-backed manual execution was not possible in this environment because the required Playwright Chromium binary is not installed. + +## Changed Files +- docs_build/dev/reports/PR_26174_BRAVO_015-child-play-uses-parent-tts-profile.md +- docs_build/dev/reports/codex_review.diff +- docs_build/dev/reports/codex_changed_files.txt +- tests/playwright/tools/MessagesTool.spec.mjs +- toolbox/messages/messages.js + +## Artifact +- tmp/PR_26174_BRAVO_015-child-play-uses-parent-tts-profile_delta.zip diff --git a/docs_build/dev/reports/PR_26174_BRAVO_017-message-play-profile-resolution.md b/docs_build/dev/reports/PR_26174_BRAVO_017-message-play-profile-resolution.md new file mode 100644 index 000000000..8d67c8d93 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_017-message-play-profile-resolution.md @@ -0,0 +1,50 @@ +# PR_26174_BRAVO_017-message-play-profile-resolution + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the existing Bravo branch; no merge and no return to main. + +## Purpose +- PASS: Scoped to Messages playback profile resolution and diagnostics. + +## Requirement Checklist +- PASS: Parent Message Play still exists in the parent row Actions area. +- PASS: Child Sentence Play still exists in child row Actions. +- PASS: Parent Play resolves the selected parent TTS Profile before speaking ordered Sentences. +- PASS: Child Play resolves Sentence -> Parent Message -> selected TTS Profile before speaking only that Sentence. +- PASS: Message playback does not read Local Preview voice selection, preview voice dropdown, or preview voice state. +- PASS: Message playback resolves Voice from the selected TTS Profile voice field. +- PASS: Playback diagnostics display Profile, Gender, Voice, Language, and Age Filter. +- PASS: Missing gender or age-filter metadata is displayed with Creator-safe `Any` defaults rather than exposing implementation details. +- PASS: The old `Select an available browser voice before preview.` message is absent from the Messages runtime and covered by negative Playwright assertions. +- PASS: Text To Speech remains the owner of profile voice/filter/audio settings; Messages only reads the selected profile for playback. +- PASS: No inline styles, inline scripts, inline handlers, style blocks, or page-local CSS were added. +- PASS: No browser-owned authoritative product data or browser-generated authoritative database keys were added. + +## Validation Lane Report +- PASS: `node --check toolbox/messages/messages.js` +- PASS: `node --check tests/playwright/tools/MessagesTool.spec.mjs` +- PASS: `rg --pcre2 -n "]*\\bsrc=)" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` returned no matches. +- PASS: `rg --pcre2 -n "localStorage|sessionStorage|imageDataUrl" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` returned no matches. +- PASS: `rg -n "Select an available browser voice before preview|Local Preview|Preview voice|data-tts-preview|data-tts-voice" toolbox/messages/index.html toolbox/messages/messages.js` returned no matches. +- PASS: `git diff --check -- toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: `npx playwright install chromium` was attempted once and timed out after three minutes without producing the required Chromium executable. The timed-out installer helper processes were stopped; the existing local API process was left running. +- BLOCKED: Fallback `npm run test:workspace-v2` reached the same missing Chromium blocker after completing static/preflight report generation. + +## Manual Validation Notes +- Reviewed `speechItemFromSegment`: child playback continues to derive `profileKey` from the parent message, not from sentence-owned voice data or preview state. +- Reviewed `browserVoiceForProfile`: Messages playback uses the selected profile voice name and never calls the Text To Speech Local Preview voice selector. +- Reviewed new diagnostics: the status log now persists `Playing:`, `Profile:`, `Gender:`, `Voice:`, `Language:`, and `Age Filter:` for playback. +- Updated Playwright assertions to verify the Hero profile diagnostics for both child Sentence Play and parent Message Play, and to keep guarding against the old preview voice error. +- Browser-backed manual execution was not possible in this environment because the required Playwright Chromium binary is not installed. + +## Changed Files +- docs_build/dev/reports/PR_26174_BRAVO_017-message-play-profile-resolution.md +- docs_build/dev/reports/codex_review.diff +- docs_build/dev/reports/codex_changed_files.txt +- tests/playwright/tools/MessagesTool.spec.mjs +- toolbox/messages/messages.js + +## Artifact +- tmp/PR_26174_BRAVO_017-message-play-profile-resolution_delta.zip diff --git a/docs_build/dev/reports/PR_26174_BRAVO_018-fix-messages-playback-source.md b/docs_build/dev/reports/PR_26174_BRAVO_018-fix-messages-playback-source.md new file mode 100644 index 000000000..88cf83146 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_018-fix-messages-playback-source.md @@ -0,0 +1,48 @@ +# PR_26174_BRAVO_018-fix-messages-playback-source + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the existing Bravo branch; no merge and no return to main. + +## Purpose +- PASS: Scoped to fixing the Messages playback source so it no longer uses Text To Speech preview-style exact browser voice validation. + +## Requirement Checklist +- PASS: Parent Message row Actions include Play, Edit, Archive/Restore, and Delete. +- PASS: Child Sentence row Actions include Play, Edit, Archive/Restore, and Delete. +- PASS: Parent Play resolves Message -> selected TTS Profile -> ordered child Sentences -> speech queue. +- PASS: Child Play resolves Sentence -> parent Message -> selected TTS Profile -> single-sentence speech queue. +- PASS: Messages playback no longer requires an exact browser voice match for the selected profile voice text. +- PASS: When a selected profile has Voice text but the browser cannot match it exactly, Messages allows Browser Speech to use its default voice. +- PASS: Diagnostics still display the selected profile values, including Profile, Gender, Voice, Language, and Age Filter. +- PASS: Messages runtime does not show `Select an available browser voice before preview.` and does not depend on Local Preview voice selection, preview voice dropdown, or preview voice state. +- PASS: No changes were made under `toolbox/text-to-speech/`. +- PASS: No inline styles, inline scripts, inline handlers, style blocks, or page-local CSS were added. +- PASS: No browser-owned authoritative product data or browser-generated authoritative database keys were added. + +## Validation Lane Report +- PASS: `node --check toolbox/messages/messages.js` +- PASS: `node --check tests/playwright/tools/MessagesTool.spec.mjs` +- PASS: `rg --pcre2 -n "]*\\bsrc=)" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` returned no matches. +- PASS: `rg --pcre2 -n "localStorage|sessionStorage|imageDataUrl" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` returned no matches. +- PASS: `rg -n "Select an available browser voice before preview|Local Preview|Preview voice|data-tts-preview|data-tts-voice" toolbox/messages/index.html toolbox/messages/messages.js` returned no matches. +- PASS: `git diff --check -- toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs` +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: Fallback `npm run test:workspace-v2` reached the same missing Chromium blocker after completing static/preflight report generation. + +## Manual Validation Notes +- Reviewed `browserVoiceForProfile`: it now returns an exact voice match when available and returns `null` when unavailable so Web Speech can use its browser default voice. +- Reviewed `assertSpeechItem`: Messages still requires selected parent TTS Profile voice text; it no longer requires that text to be installed as an exact local browser voice. +- Updated the Messages Playwright fixture to omit `Browser hero` from `speechSynthesis.getVoices()` while the selected Hero profile still has `voiceName: Browser hero`. +- Updated assertions so Child Play and Parent Play still speak with no matched browser voice while diagnostics continue to display `Voice: Browser hero`. +- Browser-backed manual execution was not possible in this environment because the required Playwright Chromium binary is not installed. + +## Changed Files +- docs_build/dev/reports/PR_26174_BRAVO_018-fix-messages-playback-source.md +- docs_build/dev/reports/codex_review.diff +- docs_build/dev/reports/codex_changed_files.txt +- tests/playwright/tools/MessagesTool.spec.mjs +- toolbox/messages/messages.js + +## Artifact +- tmp/PR_26174_BRAVO_018-fix-messages-playback-source_delta.zip diff --git a/docs_build/dev/reports/PR_26174_BRAVO_019-remove-preview-dependency-from-messages-play.md b/docs_build/dev/reports/PR_26174_BRAVO_019-remove-preview-dependency-from-messages-play.md new file mode 100644 index 000000000..a399aac62 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_019-remove-preview-dependency-from-messages-play.md @@ -0,0 +1,53 @@ +# PR_26174_BRAVO_019-remove-preview-dependency-from-messages-play + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the existing Bravo branch; no merge and no return to main. + +## Purpose +- PASS: Scoped to removing preview-style dependency and wording from Messages playback. + +## Requirement Checklist +- PASS: Parent Message row Actions include Play, Edit, Archive/Restore, and Delete. +- PASS: Child Sentence row Actions include Play, Edit, Archive/Restore, and Delete. +- PASS: Parent Play resolves Message -> selected TTS Profile -> ordered child Sentences -> speech queue. +- PASS: Child Play resolves Sentence -> parent Message -> selected TTS Profile -> single-sentence speech queue. +- PASS: Messages playback does not call Text To Speech Local Preview logic and does not read preview voice state. +- PASS: `toolbox/messages/messages.js` contains no `before preview`, no `available browser voice before preview`, and no exact preview voice error text. +- PASS: Messages runtime speech status wording now uses playback instead of preview. +- PASS: Playback diagnostics display Profile, Gender, Voice, Language, and Age Filter from the parent profile. +- PASS: If exact browser voice matching fails, Messages allows browser default speech while keeping selected profile values visible. +- PASS: Text To Speech preview strict validation was not changed. +- PASS: No inline styles, inline scripts, inline handlers, style blocks, or page-local CSS were added. +- PASS: No browser-owned authoritative product data or browser-generated authoritative database keys were added. + +## Validation Lane Report +- PASS: `node --check toolbox/messages/messages.js` +- PASS: `node --check tests/playwright/tools/MessagesTool.spec.mjs` +- PASS: `node --check tests/tools/MessagesPlaybackSource.test.mjs` +- PASS: `node --test tests/tools/MessagesPlaybackSource.test.mjs` +- PASS: `rg --pcre2 -n "]*\\bsrc=)" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs tests/tools/MessagesPlaybackSource.test.mjs` returned no matches. +- PASS: `rg --pcre2 -n "localStorage|sessionStorage|imageDataUrl" toolbox/messages/index.html toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs tests/tools/MessagesPlaybackSource.test.mjs` returned no matches. +- PASS: `rg -n "before preview|available browser voice before preview|Select an available browser voice before preview" toolbox/messages/messages.js` returned no matches. +- PASS: `rg -n "Speech preview|speech preview|previewing|browser preview|speechPreview" toolbox/messages/messages.js` returned no matches. +- PASS: `git diff --check -- toolbox/messages/messages.js tests/playwright/tools/MessagesTool.spec.mjs tests/tools/MessagesPlaybackSource.test.mjs` +- BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` could not launch because Chromium is missing at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- BLOCKED: Fallback `npm run test:workspace-v2` reached the same missing Chromium blocker after completing static/preflight report generation. + +## Manual Validation Notes +- Reviewed `browserVoiceForProfile`: it returns a matched browser voice when available, otherwise `null` so Browser Speech can use its default voice. +- Reviewed `speechItemFromSegment`: child Sentence Play still inherits the selected parent Message TTS Profile. +- Reviewed runtime copy: Messages playback no longer presents preview-status wording from `messages.js`. +- Added a Node static test to block the preview voice error text from returning to `toolbox/messages/messages.js`. +- Browser-backed manual execution was not possible in this environment because the required Playwright Chromium binary is not installed. + +## Changed Files +- docs_build/dev/reports/PR_26174_BRAVO_019-remove-preview-dependency-from-messages-play.md +- docs_build/dev/reports/codex_review.diff +- docs_build/dev/reports/codex_changed_files.txt +- tests/playwright/tools/MessagesTool.spec.mjs +- tests/tools/MessagesPlaybackSource.test.mjs +- toolbox/messages/messages.js + +## Artifact +- tmp/PR_26174_BRAVO_019-remove-preview-dependency-from-messages-play_delta.zip diff --git a/docs_build/dev/reports/PR_26174_BRAVO_020-messages-load-tts-profile-emotions.md b/docs_build/dev/reports/PR_26174_BRAVO_020-messages-load-tts-profile-emotions.md new file mode 100644 index 000000000..88b86e766 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_020-messages-load-tts-profile-emotions.md @@ -0,0 +1,68 @@ +# PR_26174_BRAVO_020-messages-load-tts-profile-emotions + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the existing Bravo branch. +- PASS: Did not merge and did not return to `main`. + +## Summary +- Added a Text To Speech-owned Message Studio profile export with profile-specific emotions. +- Updated the Messages Local API TTS profile seed/response mapping to use the Text To Speech profile contract while preserving server-owned database keys. +- Updated Messages sentence Emotion dropdowns to show only emotions available on the selected parent Message TTS Profile. +- Added/updated tests for TTS-owned profile names, filtered child emotion options, parent profile switching, and Messages playback through selected profile/emotion references. + +## Requirement Checklist +- PASS: `/toolbox/messages/index.html` continues to load Messages through external JS; TTS profile names are now supplied by the Text To Speech-owned profile contract through the Local API. +- PASS: Messages parent TTS Profile dropdown shows available TTS Profiles: `Default Balanced Profile`, `Man Profile 1`, and `Woman Profile 2`. +- PASS: Child Sentence Emotion dropdown is filtered by the selected parent TTS Profile. +- PASS: `Man Profile 1` exposes only `Calm` and `Urgent`. +- PASS: `Woman Profile 2` exposes only `Whisper` and `Robot`. +- PASS: Switching parent profile updates available child Sentence emotions. +- PASS: Messages does not duplicate TTS profile/emotion ownership; Text To Speech owns profile, voice, language, gender, age filter, pitch, rate, and volume. +- PASS: Messages still owns what to speak. +- PASS: Messages playback resolves Message/Sentence to selected parent TTS Profile and selected Sentence Emotion. +- PASS: Messages runtime still contains no `Select an available browser voice before preview` error text. +- PASS: No inline styles, inline scripts, inline handlers, or page-local CSS added. +- PASS: No browser-owned authoritative product data or browser-generated authoritative database keys added. +- PASS: Creator-safe failure text retained. + +## Validation Lane Report +- PASS: `node tests/tools/Text2SpeechShell.test.mjs` +- PASS: `node tests/tools/MessagesPlaybackSource.test.mjs` +- PASS: `node --test-name-pattern "Messages Local API seeds" tests/dev-runtime/DbSeedIntegrity.test.mjs` +- PASS: `node tests/dev-runtime/MessagesPublishValidation.test.mjs` +- PASS: Service inspection confirmed profile/emotion response shape: + - `Default Balanced Profile`: `Calm`, `Urgent` + - `Man Profile 1`: `Calm`, `Urgent` + - `Woman Profile 2`: `Whisper`, `Robot` +- FAIL/BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=chromium` + - Repo Playwright config has projects `ui` and `playwright`; no `chromium` project. +- FAIL/BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` + - Blocked before assertions: missing local Chromium executable at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- FAIL/BLOCKED: `npm run test:workspace-v2` + - Static setup ran, then blocked by the same missing Chromium executable. +- PASS: `npm run test:playwright:static` +- NOTE: Full `node tests/dev-runtime/DbSeedIntegrity.test.mjs` ran the Messages seed case successfully, then failed unrelated Local DB snapshot cases that are outside this PR scope. The scoped Messages seed case passed. + +## Manual Validation Notes +- Browser UI validation could not complete because Playwright Chromium is not installed locally. +- Static and Node validations passed for the PR-specific behavior. +- Code inspection confirmed Messages uses external JS only and the child Emotion picker no longer falls back to global unrelated emotions. +- Runtime service inspection confirmed TTS profile names and filtered emotion sets are returned through the Local API. + +## Changed Files +- `src/dev-runtime/messages/messages-postgres-service.mjs` +- `toolbox/messages/messages.js` +- `toolbox/text-to-speech/text2speech.js` +- `tests/dev-runtime/DbSeedIntegrity.test.mjs` +- `tests/dev-runtime/MessagesPublishValidation.test.mjs` +- `tests/playwright/tools/EventsTool.spec.mjs` +- `tests/playwright/tools/MessagesTool.spec.mjs` +- `tests/tools/MessagesPlaybackSource.test.mjs` +- `tests/tools/Text2SpeechShell.test.mjs` +- `docs_build/dev/reports/PR_26174_BRAVO_020-messages-load-tts-profile-emotions.md` +- `docs_build/dev/reports/codex_changed_files.txt` +- `docs_build/dev/reports/codex_review.diff` + +## ZIP +- `tmp/PR_26174_BRAVO_020-messages-load-tts-profile-emotions_delta.zip` diff --git a/docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md b/docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md new file mode 100644 index 000000000..990ea4c6e --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md @@ -0,0 +1,65 @@ +# PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract + +## Branch Validation +- PASS: Current branch is `team/BRAVO/messages`. +- PASS: Continued on the existing Bravo branch. +- PASS: Did not merge and did not return to `main`. + +## Summary +- Wired `toolbox/messages/messages.js` directly to the Text To Speech profile contract exported by `toolbox/text-to-speech/text2speech.js`. +- Added a Messages-side adapter that maps TTS contract profile names/emotions to Local API server-owned profile and emotion keys. +- Filtered the Messages parent TTS Profile dropdown to the TTS contract profiles instead of raw Messages API profile rows. +- Filtered child Sentence Emotion dropdowns to the selected parent profile's contract emotions only. +- Guarded the Text To Speech page auto-start so importing the shared contract does not boot the TTS preview UI on the Messages page. + +## Requirement Checklist +- PASS: Messages is wired to the same TTS Profile contract used by Text To Speech. +- PASS: Parent Message dropdown loads TTS Profile names from the TTS contract. +- PASS: Parent selected profile exposes Profile, Gender, Voice, Language, and Age Filter to Messages playback diagnostics. +- PASS: Child Sentence Emotion dropdown loads only emotions from the selected parent profile. +- PASS: `Man Profile 1` exposes `Neutral`, `Calm`, and `Urgent`. +- PASS: `Woman Profile 2` exposes only `Whisper` and `Robot`. +- PASS: Switching parent Profile refreshes child Sentence emotion options after save/reload. +- PASS: Parent Message Play remains routed through selected parent profile plus ordered Sentences. +- PASS: Child Sentence Play remains routed through selected parent profile plus selected Sentence Emotion. +- PASS: Messages runtime does not contain or show `Select an available browser voice before preview.` +- PASS: Text To Speech preview validation was not changed; only the shared export/auto-start guard was adjusted. +- PASS: Messages does not add Emotion Profiles or Voice Profiles sections. +- PASS: No inline styles, inline scripts, inline handlers, or page-local CSS added. +- PASS: No browser-owned authoritative product data or browser-generated authoritative database keys added. + +## Validation Lane Report +- PASS: `node tests/tools/Text2SpeechShell.test.mjs` +- PASS: `node tests/tools/MessagesPlaybackSource.test.mjs` +- PASS: `node --test-name-pattern "Messages Local API seeds" tests/dev-runtime/DbSeedIntegrity.test.mjs` +- PASS: `node tests/dev-runtime/MessagesPublishValidation.test.mjs` +- PASS: Contract/API runtime inspection confirmed matching profile emotion sets: + - `Default Balanced Profile`: `Calm`, `Urgent` + - `Man Profile 1`: `Neutral`, `Calm`, `Urgent` + - `Woman Profile 2`: `Whisper`, `Robot` +- FAIL/BLOCKED: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright` + - Blocked before assertions: missing local Chromium executable at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +- PASS: `npm run test:playwright:static` + +## Manual Validation Notes +- Browser UI validation could not complete because the local Playwright Chromium executable is missing. +- Static and Node validations passed for the PR-specific wiring. +- Code inspection confirms Messages imports the TTS contract and no longer assigns raw `voicePayload.ttsProfiles` directly into UI profile state. +- Code inspection confirms Messages playback runtime still has no preview voice validation text. + +## Changed Files +- `src/dev-runtime/messages/messages-postgres-service.mjs` +- `toolbox/messages/messages.js` +- `toolbox/text-to-speech/text2speech.js` +- `tests/dev-runtime/DbSeedIntegrity.test.mjs` +- `tests/dev-runtime/MessagesPublishValidation.test.mjs` +- `tests/playwright/tools/EventsTool.spec.mjs` +- `tests/playwright/tools/MessagesTool.spec.mjs` +- `tests/tools/MessagesPlaybackSource.test.mjs` +- `tests/tools/Text2SpeechShell.test.mjs` +- `docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md` +- `docs_build/dev/reports/codex_changed_files.txt` +- `docs_build/dev/reports/codex_review.diff` + +## ZIP +- `tmp/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract_delta.zip` diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index de36ff823..9bb359709 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,6 +1,12 @@ -docs_build/dev/ProjectInstructions/backlog/BACKLOG_MASTER.md -docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-governance-closeout-report.md -docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-manual-validation-notes.md -docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-instruction-compliance-checklist.md +docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md docs_build/dev/reports/codex_changed_files.txt docs_build/dev/reports/codex_review.diff +src/dev-runtime/messages/messages-postgres-service.mjs +tests/dev-runtime/DbSeedIntegrity.test.mjs +tests/dev-runtime/MessagesPublishValidation.test.mjs +tests/playwright/tools/EventsTool.spec.mjs +tests/playwright/tools/MessagesTool.spec.mjs +tests/tools/MessagesPlaybackSource.test.mjs +tests/tools/Text2SpeechShell.test.mjs +toolbox/messages/messages.js +toolbox/text-to-speech/text2speech.js diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 9391b9ac1..59c87d98f 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,138 +1,920 @@ -diff --git a/docs_build/dev/ProjectInstructions/backlog/BACKLOG_MASTER.md b/docs_build/dev/ProjectInstructions/backlog/BACKLOG_MASTER.md -index ab10302ff..09cc34579 100644 ---- a/docs_build/dev/ProjectInstructions/backlog/BACKLOG_MASTER.md -+++ b/docs_build/dev/ProjectInstructions/backlog/BACKLOG_MASTER.md -@@ -136,3 +136,26 @@ - - Tile overlay status rules installed. - - Deprecation workflow installed. - - Team start commands installed. -+ -+- [x] OWNER - Governance hygiene initiative complete -+ -+ Notes: -+ - Workstream Hygiene governance verified. -+ - PI Closeout governance verified. -+ - GitHub Hygiene Audit governance verified. -+ - EOD Workstream Closeout governance verified. -+ - GitHub-authoritative workstream enforcement verified through push, local/origin sync, and GitHub hygiene review requirements. -+ -+- [x] OWNER - Multi-team workflow governance complete -+ -+ Notes: -+ - Sequential Codex Queue governance verified through the all-team sequential PR execution model. -+ - OWNER merge approval and EOD merge governance verified. -+ - Team ownership governance verified through the authoritative ownership map. -+ -+- [x] OWNER - Repository hygiene governance complete -+ -+ Notes: -+ - Open PR, draft PR, local branch, and remote branch review requirements verified. -+ - Recommendation-only hygiene audit process verified. -+ - Branch deletion and PR closure remain prohibited without explicit owner approval. -diff --git a/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-governance-closeout-report.md b/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-governance-closeout-report.md -new file mode 100644 -index 0000000000000000000000000000000000000000..25afe742506df1b222ffa8f8e3056e6f1abb2f6f -GIT binary patch -literal 2315 -zcmd5;O>^5e5bZ59`V~9lt10GVrk$}*vZ^RbYl$W0NW$F&rwVJNXKIbH+86nuo?#y` -zK5}CX9r6(9PoZ+Nx_4X@ae=!yNf#d{kF^{!m(0{~VqFKDz!CBIazVG$)#PY4Q5dkl;);)TNHMy5^gKdrbmZ!q%CR -zL;>nnUIXEY={2;JZ{k6yoQarHZ@2_=kU9JUudkb9UpC%!4WiX#)VSlgudB)k -z4KbgSDvniwkMNg6_e@Z~CdG(oNR=nwG7}0UeMK-_ZUi>>86;6t67Ugo=LrmZZp&I@ -zw-jYM07+_y#6cxjq#Cp7{B|;5dR++UU!CTCUB^glOBUatW$~0%6@yL;&kW`TRl2?V -zb2(cjovJnUSj^xnBlXY7_t`sBI(CSYl&@KBG_S1 -fY%9q}vp;6ZW$>oBdrgs&;^2FiG2cfj=UmwT(`Pdp - -literal 0 -HcmV?d00001 - -diff --git a/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-instruction-compliance-checklist.md -new file mode 100644 -index 000000000..0c4b589e4 ---- /dev/null -+++ b/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-instruction-compliance-checklist.md -@@ -0,0 +1,22 @@ -+# Instruction Compliance Checklist -+ -+PR: $pr -+ -+- [x] Read docs_build/dev/PROJECT_INSTRUCTIONS.md. -+- [x] Read docs_build/dev/PROJECT_MULTI_PC.txt. -+- [x] Verified current branch was the approved OWNER workstream branch before PR 033 branch creation. -+- [x] Verified repository was clean/synced before PR 033 edits. -+- [x] Verified PR TEAM token is OWNER. -+- [x] Verified OWNER governance closeout scope matches TEAM ownership. -+- [x] Updated only scoped governance backlog and required reports. -+- [x] Ran git diff --check. -+- [x] Verified Workstream Hygiene governance exists. -+- [x] Verified PI Closeout governance exists. -+- [x] Verified GitHub Hygiene Audit governance exists. -+- [x] Verified EOD Workstream Closeout governance exists. -+- [x] Verified GitHub-authoritative workstream controls exist. -+- [x] Verified Sequential Codex Queue governance exists. -+- [x] Verified Alpha/Beta/Gamma ownership governance exists. -+- [x] Documented skipped Playwright and samples lanes. -+- [x] Created required Codex reports. -+- [x] Created repo-structured delta ZIP under mp/. -diff --git a/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-manual-validation-notes.md b/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-manual-validation-notes.md -new file mode 100644 -index 000000000..4a3661d3e ---- /dev/null -+++ b/docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-manual-validation-notes.md -@@ -0,0 +1,25 @@ -+# Manual Validation Notes -+ -+PR: $pr -+ -+## Checks Performed -+ -+- Verified Workstream Hygiene governance exists. -+- Verified PI Closeout governance exists. -+- Verified GitHub Hygiene Audit governance exists. -+- Verified EOD Workstream Closeout governance exists. -+- Verified GitHub-authoritative workstream controls through push, local/origin sync, and GitHub hygiene audit requirements. -+- Verified Sequential Codex Queue governance through the all-team sequential PR execution model. -+- Verified Alpha/Beta/Gamma ownership governance in the authoritative team ownership map and NATO-normalized active ProjectInstructions team names. -+- Confirmed no runtime code changed. -+- Confirmed no feature work was added. -+- Confirmed no branch deletion or PR closure was performed. -+ -+## Manual Result -+ -+PASS -+ -+## Skipped Validation -+ -+- Playwright was not run because no UI or runtime behavior changed. -+- Samples were not run because no samples or sample-impacting runtime changed. -diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt -index bef983eeb..de36ff823 100644 ---- a/docs_build/dev/reports/codex_changed_files.txt -+++ b/docs_build/dev/reports/codex_changed_files.txt -@@ -1,13 +1,6 @@ --docs_build/dev/PROJECT_INSTRUCTIONS.md --docs_build/dev/ProjectInstructions/addendums/multi_team.md --docs_build/dev/reports/PR_26172_OWNER_030-workstream-hygiene-governance-instruction-compliance-checklist.md --docs_build/dev/reports/PR_26172_OWNER_030-workstream-hygiene-governance-manual-validation-notes.md --docs_build/dev/reports/PR_26172_OWNER_030-workstream-hygiene-governance.md --docs_build/dev/reports/PR_26172_OWNER_031-pi-closeout-governance-instruction-compliance-checklist.md --docs_build/dev/reports/PR_26172_OWNER_031-pi-closeout-governance-manual-validation-notes.md --docs_build/dev/reports/PR_26172_OWNER_031-pi-closeout-governance.md --docs_build/dev/reports/PR_26172_OWNER_032-github-hygiene-audit-template-instruction-compliance-checklist.md --docs_build/dev/reports/PR_26172_OWNER_032-github-hygiene-audit-template-manual-validation-notes.md --docs_build/dev/reports/PR_26172_OWNER_032-github-hygiene-audit-template.md -+docs_build/dev/ProjectInstructions/backlog/BACKLOG_MASTER.md -+docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-governance-closeout-report.md -+docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-manual-validation-notes.md -+docs_build/dev/reports/PR_26172_OWNER_033-governance-hygiene-closeout-instruction-compliance-checklist.md - docs_build/dev/reports/codex_changed_files.txt - docs_build/dev/reports/codex_review.diff +diff --git a/src/dev-runtime/messages/messages-postgres-service.mjs b/src/dev-runtime/messages/messages-postgres-service.mjs +index e52e3632d..a326dc9ae 100644 +--- a/src/dev-runtime/messages/messages-postgres-service.mjs ++++ b/src/dev-runtime/messages/messages-postgres-service.mjs +@@ -1,4 +1,8 @@ + import { randomBytes } from "node:crypto"; ++import { ++ createMessageStudioDefaultTtsProfiles, ++ createMessageStudioTtsProfileOptions, ++} from "../../../toolbox/text-to-speech/text2speech.js"; + import { createPostgresConnectionClient } from "../persistence/postgres-connection-client.mjs"; + import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; + +@@ -14,6 +18,7 @@ const SEED_CATEGORY_NAMES = Object.freeze([ + "Notification", + ]); + const SEED_EMOTION_PROFILES = Object.freeze([ ++ Object.freeze({ description: "Balanced spoken delivery for general narration or dialog.", name: "Neutral", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), + Object.freeze({ description: "Neutral spoken delivery for general narration or dialog.", name: "Calm", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), + Object.freeze({ description: "Fast, alert delivery for warnings and immediate danger.", name: "Urgent", pauseAfterMs: 80, pauseBeforeMs: 0, pitch: 1.08, rate: 1.15, volume: 1 }), + Object.freeze({ description: "Quiet delivery for secret, stealth, or intimate lines.", name: "Whisper", pauseAfterMs: 180, pauseBeforeMs: 80, pitch: 0.95, rate: 0.9, volume: 0.55 }), +@@ -21,79 +26,23 @@ const SEED_EMOTION_PROFILES = Object.freeze([ + Object.freeze({ description: "Bright delivery for reveals, wins, and high-energy moments.", name: "Excited", pauseAfterMs: 100, pauseBeforeMs: 0, pitch: 1.12, rate: 1.12, volume: 1 }), + Object.freeze({ description: "Soft delivery for loss, regret, or reflective moments.", name: "Sad", pauseAfterMs: 220, pauseBeforeMs: 100, pitch: 0.9, rate: 0.85, volume: 0.8 }), + Object.freeze({ description: "Measured delivery for suspense, hidden lore, or strange events.", name: "Mysterious", pauseAfterMs: 260, pauseBeforeMs: 120, pitch: 0.92, rate: 0.88, volume: 0.85 }), ++ Object.freeze({ description: "Synthetic delivery for mechanical or artificial characters.", name: "Robot", pauseAfterMs: 120, pauseBeforeMs: 40, pitch: 0.82, rate: 0.92, volume: 0.9 }), + ]); +-const SEED_TTS_PROFILES = Object.freeze([ +- Object.freeze({ +- description: "Clear story narration voice profile for authored message playback.", +- language: "en-US", +- name: "Narrator", +- pitch: 1, +- providerKey: "browser-speech", +- rate: 1, +- voiceName: "Browser default", +- volume: 1, +- }), +- Object.freeze({ +- description: "Confident player-facing hero voice profile.", +- language: "en-US", +- name: "Hero", +- pitch: 1.04, +- providerKey: "browser-speech", +- rate: 1.02, +- voiceName: "Browser hero", +- volume: 1, +- }), +- Object.freeze({ +- description: "Friendly vendor dialog voice profile.", +- language: "en-US", +- name: "Merchant", +- pitch: 0.98, +- providerKey: "browser-speech", +- rate: 0.95, +- voiceName: "Browser merchant", +- volume: 1, +- }), +- Object.freeze({ +- description: "Synthetic utility character voice profile.", +- language: "en-US", +- name: "Robot", +- pitch: 0.88, +- providerKey: "browser-speech", +- rate: 0.9, +- voiceName: "Browser robot", +- volume: 0.9, +- }), +- Object.freeze({ +- description: "Low creature dialog voice profile.", +- language: "en-US", +- name: "Monster", +- pitch: 0.78, +- providerKey: "browser-speech", +- rate: 0.86, +- voiceName: "Browser monster", +- volume: 1, +- }), +- Object.freeze({ +- description: "Balanced local browser playback option until authored TTS profiles are available.", +- language: "en-US", +- name: "Default Balanced TTS Profile", +- pitch: 1, +- providerKey: "browser-speech", +- rate: 1, +- voiceName: "Browser default", +- volume: 1, +- }), +- Object.freeze({ +- description: "Narration-focused preview configuration for future spoken story text.", +- language: "en-US", +- name: "Narration Preview", +- pitch: 0.95, +- providerKey: "browser-speech", +- rate: 0.9, +- voiceName: "Browser narration", +- volume: 0.9, +- }), +-]); ++const MESSAGE_STUDIO_TTS_PROFILE_OPTIONS = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) ++ .map((profile) => Object.freeze({ ++ ...profile, ++ emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), ++ }))); ++const SEED_TTS_PROFILES = Object.freeze(MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.map((profile) => Object.freeze({ ++ description: `${profile.name} from Text To Speech profile ownership.`, ++ language: profile.language || "en-US", ++ name: profile.name, ++ pitch: 1, ++ providerKey: profile.providerKey || "browser-speech", ++ rate: 1, ++ voiceName: profile.voiceName || "Default browser voice", ++ volume: 1, ++}))); + const SUPPORTED_TTS_PROVIDER_KEYS = Object.freeze([ + "browser-speech", + "elevenlabs", +@@ -408,13 +357,54 @@ function ttsEmotionSettingFromEmotionProfile(profile) { + }; + } + ++function messageStudioTtsProfileOption(row) { ++ const rowName = normalizeText(row?.name).trim().toLowerCase(); ++ const rowKey = normalizeText(row?.key).trim(); ++ return MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.find((profile) => { ++ return profile.key === rowKey || normalizeText(profile.name).trim().toLowerCase() === rowName; ++ }) || null; ++} ++ ++function emotionSettingsForTtsProfileRow(row, emotionRows = []) { ++ const option = messageStudioTtsProfileOption(row); ++ if (!option) { ++ return []; ++ } ++ const activeEmotionProfiles = emotionRows ++ .map((profileRow) => emotionProfileFromRow(profileRow)) ++ .filter((profile) => profile.active !== false); ++ const byLabel = new Map(activeEmotionProfiles.map((profile) => [normalizeText(profile.name).trim().toLowerCase(), profile])); ++ const byEmotion = new Map(activeEmotionProfiles.map((profile) => [emotionSettingKey(profile.name), profile])); ++ return option.emotionSettings ++ .map((setting) => { ++ const emotionProfile = byLabel.get(normalizeText(setting.emotionLabel).trim().toLowerCase()) ++ || byEmotion.get(emotionSettingKey(setting.emotion)) ++ || null; ++ if (!emotionProfile) { ++ return null; ++ } ++ return { ++ ...ttsEmotionSettingFromEmotionProfile(emotionProfile), ++ pitch: Number(setting.pitch), ++ rate: Number(setting.rate), ++ ssmlLikePreset: setting.ssmlLikePreset || "normal", ++ volume: Number(setting.volume), ++ }; ++ }) ++ .filter(Boolean); ++} ++ + function ttsProfileFromRow(row, emotionSettings = []) { ++ const profileOption = messageStudioTtsProfileOption(row); + return { + active: activeFromDatabase(row.active), ++ age: profileOption?.age || "", ++ ageFilter: profileOption?.ageFilter || profileOption?.age || "", + createdAt: row.createdAt, + createdBy: row.createdBy, + description: row.description || "", + emotionSettings, ++ gender: profileOption?.gender || "", + key: row.key, + language: row.language, + name: row.name, +@@ -424,6 +414,7 @@ function ttsProfileFromRow(row, emotionSettings = []) { + status: activeFromDatabase(row.active) ? "Active" : "Inactive", + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, ++ voice: profileOption?.voice || row.voiceName || "", + voiceName: row.voiceName || "", + volume: Number(row.volume), + }; +@@ -808,11 +799,10 @@ export class MessagesPostgresService { + + async listTtsProfiles() { + await this.ensureReady(); +- const emotionSettings = (await this.tableRows("messages_emotion_profiles")) +- .map((profileRow) => emotionProfileFromRow(profileRow)) +- .filter((profile) => profile.active !== false) +- .map(ttsEmotionSettingFromEmotionProfile); +- return (await this.tableRows("messages_tts_profiles")).sort(compareName).map((row) => ttsProfileFromRow(row, emotionSettings)); ++ const emotionRows = await this.tableRows("messages_emotion_profiles"); ++ return (await this.tableRows("messages_tts_profiles")) ++ .sort(compareName) ++ .map((row) => ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows))); + } + + async getTtsProfile(key) { +@@ -821,11 +811,8 @@ export class MessagesPostgresService { + if (!row) { + throw httpError("Voice profile was not found.", 404); + } +- const emotionSettings = (await this.tableRows("messages_emotion_profiles")) +- .map((profileRow) => emotionProfileFromRow(profileRow)) +- .filter((profile) => profile.active !== false) +- .map(ttsEmotionSettingFromEmotionProfile); +- return ttsProfileFromRow(row, emotionSettings); ++ const emotionRows = await this.tableRows("messages_emotion_profiles"); ++ return ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows)); + } + + async findTtsProfileByNameRaw(name) { +@@ -843,9 +830,9 @@ export class MessagesPostgresService { + } + + async defaultVoiceProfileKeyRaw() { +- const narrator = await this.findTtsProfileByNameRaw("Narrator"); +- if (narrator) { +- return narrator.key; ++ const defaultProfile = await this.findTtsProfileByNameRaw("Default Balanced Profile"); ++ if (defaultProfile) { ++ return defaultProfile.key; + } + const fallback = (await this.tableRows("messages_tts_profiles")).sort(compareName)[0]; + if (!fallback) { +@@ -911,11 +898,8 @@ export class MessagesPostgresService { + volume: values.volume, + }); + const row = await this.rowByKey("messages_tts_profiles", key); +- const emotionSettings = (await this.tableRows("messages_emotion_profiles")) +- .map((profileRow) => emotionProfileFromRow(profileRow)) +- .filter((profile) => profile.active !== false) +- .map(ttsEmotionSettingFromEmotionProfile); +- return ttsProfileFromRow(row, emotionSettings); ++ const emotionRows = await this.tableRows("messages_emotion_profiles"); ++ return ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows)); + } + + async createTtsProfile(input = {}, actorKey = "") { +diff --git a/tests/dev-runtime/DbSeedIntegrity.test.mjs b/tests/dev-runtime/DbSeedIntegrity.test.mjs +index 4b9406e50..fe3f5e70f 100644 +--- a/tests/dev-runtime/DbSeedIntegrity.test.mjs ++++ b/tests/dev-runtime/DbSeedIntegrity.test.mjs +@@ -95,17 +95,20 @@ test("Messages Local API seeds through the Postgres service and preserves respon + assert.ok(urgent, "Messages emotion profiles should include Urgent"); + + const ttsProfiles = await apiJson(server.baseUrl, "/api/messages/tts-profiles"); +- const narrator = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Narrator"); +- assert.ok(narrator, "Messages voice profiles should include Narrator"); +- assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced TTS Profile"), true); +- assert.equal(ttsProfiles.ttsProfiles[0].emotionSettings.some((setting) => setting.emotionLabel === "Urgent"), true); ++ const manProfile = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Man Profile 1"); ++ const womanProfile = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Woman Profile 2"); ++ assert.ok(manProfile, "Messages TTS profiles should include Man Profile 1 from Text To Speech"); ++ assert.ok(womanProfile, "Messages TTS profiles should include Woman Profile 2 from Text To Speech"); ++ assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced Profile"), true); ++ assert.deepEqual(manProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Neutral", "Calm", "Urgent"]); ++ assert.deepEqual(womanProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Whisper", "Robot"]); + + const created = await apiJson(server.baseUrl, "/api/messages/messages", { + body: JSON.stringify({ + emotionProfileKey: urgent.key, + messageText: "Postgres-backed message text.", + name: "Postgres Cutover Message", +- voiceProfileKey: narrator.key, ++ voiceProfileKey: manProfile.key, + }), + method: "POST", + }); +@@ -113,7 +116,7 @@ test("Messages Local API seeds through the Postgres service and preserves respon + assert.equal(created.message.categoryName, "Dialog"); + assert.equal(created.message.emotionProfileName, "Urgent"); + assert.equal(created.message.messageText, "Postgres-backed message text."); +- assert.equal(created.message.voiceProfileName, "Narrator"); ++ assert.equal(created.message.voiceProfileName, "Man Profile 1"); + + const segment = await apiJson(server.baseUrl, "/api/messages/segments", { + body: JSON.stringify({ +@@ -121,13 +124,13 @@ test("Messages Local API seeds through the Postgres service and preserves respon + emotionProfileKey: urgent.key, + messageKey: created.message.key, + segmentText: "Postgres-backed message part.", +- voiceProfileKey: narrator.key, ++ voiceProfileKey: manProfile.key, + }), + method: "POST", + }); + assert.equal(segment.segment.messageName, "Postgres Cutover Message"); + assert.equal(segment.segment.emotionProfileName, "Urgent"); +- assert.equal(segment.segment.voiceProfileName, "Narrator"); ++ assert.equal(segment.segment.voiceProfileName, "Man Profile 1"); + + const list = await apiJson(server.baseUrl, "/api/messages/messages"); + const listed = list.messages.find((message) => message.key === created.message.key); +diff --git a/tests/dev-runtime/MessagesPublishValidation.test.mjs b/tests/dev-runtime/MessagesPublishValidation.test.mjs +index 1afe0f8d4..0fcec6b18 100644 +--- a/tests/dev-runtime/MessagesPublishValidation.test.mjs ++++ b/tests/dev-runtime/MessagesPublishValidation.test.mjs +@@ -29,7 +29,7 @@ test("Messages publish validation passes publish-ready message configuration", a + const { service } = createServiceHarness(); + + const emotion = (await service.listEmotionProfiles()).find((profile) => profile.name === "Calm"); +- const voice = (await service.listTtsProfiles()).find((profile) => profile.name === "Narrator"); ++ const voice = (await service.listTtsProfiles()).find((profile) => profile.name === "Man Profile 1"); + assert.ok(emotion); + assert.ok(voice); + +diff --git a/tests/playwright/tools/EventsTool.spec.mjs b/tests/playwright/tools/EventsTool.spec.mjs +index 7ddf63e3b..d17be8dd4 100644 +--- a/tests/playwright/tools/EventsTool.spec.mjs ++++ b/tests/playwright/tools/EventsTool.spec.mjs +@@ -30,15 +30,15 @@ async function createMessage(server) { + const emotionResult = await jsonRequest(`${server.baseUrl}/api/messages/emotion-profiles`); + const voiceResult = await jsonRequest(`${server.baseUrl}/api/messages/tts-profiles`); + const urgent = emotionResult.payload.data.emotionProfiles.find((profile) => profile.name === "Urgent"); +- const narrator = voiceResult.payload.data.ttsProfiles.find((profile) => profile.name === "Narrator"); ++ const ttsProfile = voiceResult.payload.data.ttsProfiles.find((profile) => profile.name === "Man Profile 1"); + expect(urgent).toBeTruthy(); +- expect(narrator).toBeTruthy(); ++ expect(ttsProfile).toBeTruthy(); + const messageResult = await jsonRequest(`${server.baseUrl}/api/messages/messages`, { + body: JSON.stringify({ + emotionProfileKey: urgent.key, + messageText: "Open the ancient door.", + name: "Door Prompt", +- voiceProfileKey: narrator.key, ++ voiceProfileKey: ttsProfile.key, + }), + method: "POST", + }); +diff --git a/tests/playwright/tools/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs +index 3461f62e2..c4322ef24 100644 +--- a/tests/playwright/tools/MessagesTool.spec.mjs ++++ b/tests/playwright/tools/MessagesTool.spec.mjs +@@ -136,6 +136,15 @@ async function addSentence(page, values) { + await page.locator("[data-messages-segment-commit='__new__']").click(); + } + ++async function ensureSentencesExpanded(page, messageName) { ++ const messageRow = page.locator("[data-messages-row]").filter({ hasText: messageName }); ++ if (await page.locator("[data-messages-parts-host]").count() === 0) { ++ await messageRow.getByRole("button", { name: "Sentences" }).click(); ++ } ++ await expect(page.locator("[data-messages-parts-host]")).toBeVisible(); ++ return messageRow; ++} ++ + async function expectPlaybackDiagnostics(page, { + ageFilter = "Any", + gender = "Any", +@@ -187,7 +196,7 @@ async function voiceProfiles(server) { + return profilesResult.payload.data.ttsProfiles; + } + +-async function createReferencedSentence(server, message, emotionName = "Urgent", voiceName = "Narrator") { ++async function createReferencedSentence(server, message, emotionName = "Urgent", voiceName = "Man Profile 1") { + const emotion = await emotionProfile(server, emotionName); + const voice = await voiceProfile(server, voiceName); + expect(emotion).toBeTruthy(); +@@ -225,6 +234,8 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + await expect(page.getByText("Reusable Assets", { exact: true })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Emotion Profiles" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Voice Profiles" })).toHaveCount(0); ++ await expect(page.getByText("TTS Profile / Emotion Settings", { exact: true })).toHaveCount(0); ++ await expect(page.getByText("Emotion Settings", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Emotion Profiles")).toHaveCount(0); + await expect(page.getByLabel("Voice Profiles")).toHaveCount(0); + await expect(page.getByText("Message Parts", { exact: true })).toHaveCount(0); +@@ -242,6 +253,12 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + await expect(page.locator("[data-messages-row-editor='__new__']")).toBeVisible(); + await expect(page.locator("[data-messages-row-editor='__new__'] td")).toHaveCount(4); + await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]")).toBeVisible(); ++ await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile] option")).toHaveText([ ++ "Select TTS profile", ++ "Default Balanced Profile", ++ "Man Profile 1", ++ "Woman Profile 2", ++ ]); + await expect(page.locator("[data-messages-row-editor='__new__']").getByRole("button", { name: "Save" })).toBeVisible(); + await expect(page.locator("[data-messages-row-editor='__new__']").getByRole("button", { name: "Cancel" })).toBeVisible(); + +@@ -253,18 +270,18 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + + await addMessage(page, { + name: "Bat Encounter", +- ttsProfile: "Hero", ++ ttsProfile: "Man Profile 1", + }); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Bat Encounter."); + const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }); +- await expect(messageRow).toContainText("Hero"); ++ await expect(messageRow).toContainText("Man Profile 1"); + await expect(messageRow.getByRole("button", { name: "Sentences" })).toBeVisible(); + await expect(messageRow.getByRole("button", { name: "Edit" })).toBeVisible(); + await expect(messageRow.getByRole("button", { name: "Archive" })).toBeVisible(); + await expect(messageRow.getByRole("button", { name: "Delete" })).toBeEnabled(); + await expect(messageRow.getByRole("button", { name: "Play" })).toBeVisible(); + +- await messageRow.getByRole("button", { name: "Sentences" }).click(); ++ await ensureSentencesExpanded(page, "Bat Encounter"); + await expect(page.locator("[data-messages-parts-host]")).toBeVisible(); + const partsTable = page.getByLabel("Bat Encounter Sentences"); + await expect(partsTable.getByRole("columnheader")).toHaveText(["Order", "Text", "Emotion", "Actions"]); +@@ -273,6 +290,12 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + await page.getByRole("button", { name: "Add Sentence" }).click(); + await expect(page.locator("[data-messages-segment-editor='__new__'] td")).toHaveCount(4); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-voice]")).toHaveCount(0); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ ++ "Select emotion", ++ "Neutral", ++ "Calm", ++ "Urgent", ++ ]); + await page.locator("[data-messages-segment-commit='__new__']").click(); + await expect(page.locator("[data-messages-validation-errors]")).toContainText("Sentence text is required."); + await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion is required."); +@@ -315,8 +338,9 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + await expectPlaybackDiagnostics(page, { +- profile: "Hero", +- voice: "Browser hero", ++ gender: "Male", ++ profile: "Man Profile 1", ++ voice: "Default browser voice", + }); + + await addSentence(page, { +@@ -348,8 +372,9 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + await expectPlaybackDiagnostics(page, { +- profile: "Hero", +- voice: "Browser hero", ++ gender: "Male", ++ profile: "Man Profile 1", ++ voice: "Default browser voice", + }); + + await page.getByRole("button", { name: "Run Validation" }).click(); +@@ -388,8 +413,9 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + await expectPlaybackDiagnostics(page, { +- profile: "Hero", +- voice: "Browser hero", ++ gender: "Male", ++ profile: "Man Profile 1", ++ voice: "Default browser voice", + }); + await page.getByRole("button", { name: "Stop Speech" }).click(); + await expect(page.locator("[data-messages-speech-status]")).toHaveText("Speech playback stopped."); +@@ -459,24 +485,25 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + 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("Saved message Bat Encounter Updated."); +- await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter Updated" })).toContainText("Hero"); ++ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter Updated" })).toContainText("Man Profile 1"); + + const updatedMessage = await createdMessage(failures.server, "Bat Encounter Updated"); + expect(updatedMessage).toEqual(expect.objectContaining({ + active: true, + categoryName: "Dialog", +- voiceProfileName: "Hero", ++ voiceProfileName: "Man Profile 1", + })); + expect(updatedMessage.key).toMatch(ULID_PATTERN); + expect(updatedMessage).not.toHaveProperty("rate"); + expect(updatedMessage).not.toHaveProperty("pitch"); + expect(updatedMessage).not.toHaveProperty("volume"); + +- const heroProfile = (await voiceProfiles(failures.server)).find((profile) => profile.name === "Hero"); +- expect(heroProfile).toEqual(expect.objectContaining({ ++ const manProfile = (await voiceProfiles(failures.server)).find((profile) => profile.name === "Man Profile 1"); ++ expect(manProfile).toEqual(expect.objectContaining({ ++ gender: "male", + language: "en-US", + providerKey: "browser-speech", +- voiceName: "Browser hero", ++ voiceName: "Default browser voice", + })); + + await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter Updated" }).getByRole("button", { name: "Delete" }).click(); +@@ -492,13 +519,105 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + } + }); + ++test("Message Studio loads Text To Speech profiles and filters sentence emotions by selected profile", async ({ page }) => { ++ const failures = await openMessagesPage(page); ++ ++ try { ++ await page.getByRole("button", { name: "Add Message" }).click(); ++ const profileOptions = page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile] option"); ++ await expect(profileOptions).toHaveText([ ++ "Select TTS profile", ++ "Default Balanced Profile", ++ "Man Profile 1", ++ "Woman Profile 2", ++ ]); ++ await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill("Profile Filter Test"); ++ await page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]").selectOption({ label: "Man Profile 1" }); ++ await page.locator("[data-messages-commit='__new__']").click(); ++ await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Profile Filter Test."); ++ ++ await ensureSentencesExpanded(page, "Profile Filter Test"); ++ await page.getByRole("button", { name: "Add Sentence" }).click(); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ ++ "Select emotion", ++ "Neutral", ++ "Calm", ++ "Urgent", ++ ]); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Whisper"); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Robot"); ++ await page.locator("[data-messages-segment-cancel='__new__']").click(); ++ ++ const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Profile Filter Test" }); ++ await messageRow.getByRole("button", { name: "Edit" }).click(); ++ await page.locator("[data-messages-row-editor] [data-message-tts-profile]").selectOption({ label: "Woman Profile 2" }); ++ await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); ++ await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Profile Filter Test."); ++ ++ await ensureSentencesExpanded(page, "Profile Filter Test"); ++ await page.getByRole("button", { name: "Add Sentence" }).click(); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ ++ "Select emotion", ++ "Whisper", ++ "Robot", ++ ]); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Calm"); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Urgent"); ++ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill("1"); ++ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill("Robot voice check."); ++ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: "Robot" }); ++ await page.locator("[data-messages-segment-commit='__new__']").click(); ++ await expect(page.locator("[data-messages-log]")).toHaveText("Saved sentence 1."); ++ ++ const sentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Robot voice check." }); ++ await page.evaluate(() => { ++ window.__spokenUtterances = []; ++ }); ++ await sentenceRow.getByRole("button", { name: "Play" }).click(); ++ await page.waitForFunction(() => window.__spokenUtterances.length === 1); ++ const spokenSentence = await page.evaluate(() => window.__spokenUtterances); ++ expect(spokenSentence).toEqual([expect.objectContaining({ ++ pitch: 0.82, ++ rate: 0.92, ++ text: "Robot voice check.", ++ volume: 0.9, ++ })]); ++ await expectPlaybackDiagnostics(page, { ++ gender: "Female", ++ profile: "Woman Profile 2", ++ voice: "Default browser voice", ++ }); ++ await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); ++ await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); ++ ++ await page.evaluate(() => { ++ window.__spokenUtterances = []; ++ }); ++ await messageRow.getByRole("button", { name: "Play" }).click(); ++ await page.waitForFunction(() => window.__spokenUtterances.length === 1); ++ const spokenMessage = await page.evaluate(() => window.__spokenUtterances); ++ expect(spokenMessage.map((utterance) => utterance.text)).toEqual(["Robot voice check."]); ++ await expectPlaybackDiagnostics(page, { ++ gender: "Female", ++ profile: "Woman Profile 2", ++ voice: "Default browser voice", ++ }); ++ ++ expect(failures.failedRequests).toEqual([]); ++ expect(failures.pageErrors).toEqual([]); ++ expect(failures.consoleErrors).toEqual([]); ++ } finally { ++ await closeMessagesRun(failures, page); ++ } ++}); ++ + test("Message Studio disables Delete when a message is referenced", async ({ page }) => { + const failures = await openMessagesPage(page); + + try { + await addMessage(page, { + name: "Referenced Encounter", +- ttsProfile: "Narrator", ++ ttsProfile: "Man Profile 1", + }); + const message = await createdMessage(failures.server, "Referenced Encounter"); + const segment = await createReferencedSentence(failures.server, message); +diff --git a/tests/tools/MessagesPlaybackSource.test.mjs b/tests/tools/MessagesPlaybackSource.test.mjs +index cea5e43e1..b0389d19a 100644 +--- a/tests/tools/MessagesPlaybackSource.test.mjs ++++ b/tests/tools/MessagesPlaybackSource.test.mjs +@@ -9,3 +9,20 @@ test("Messages playback runtime does not include preview voice validation text", + assert.equal(source.includes("available browser voice before preview"), false); + assert.equal(source.includes("Select an available browser voice before preview"), false); + }); ++ ++test("Messages sentence emotion picker does not fall back to unrelated global emotions", async () => { ++ const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); ++ ++ assert.equal(source.includes("selectOptionsWithCurrent"), false); ++ assert.equal(source.includes("return options.length ? options :"), false); ++}); ++ ++test("Messages wires profile dropdowns through the Text To Speech profile contract", async () => { ++ const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); ++ ++ assert.equal(source.includes("../text-to-speech/text2speech.js"), true); ++ assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), true); ++ assert.equal(source.includes("createMessageStudioTtsProfileOptions"), true); ++ assert.equal(source.includes("state.voiceProfiles = voicePayload.ttsProfiles || []"), false); ++ assert.equal(source.includes("messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || [])"), true); ++}); +diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs +index fa54dcbcc..eea35e2cb 100644 +--- a/tests/tools/Text2SpeechShell.test.mjs ++++ b/tests/tools/Text2SpeechShell.test.mjs +@@ -7,6 +7,7 @@ import { + TTS_PROVIDER_ADAPTER_PLAN, + createDefaultTextToSpeechProfiles, + createEmotionProfile, ++ createMessageStudioDefaultTtsProfiles, + createMessageStudioTtsProfileOptions, + createSpeechPreviewRequest, + createTextToSpeechProfile, +@@ -68,6 +69,7 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai + test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { + const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; + const defaults = createDefaultTextToSpeechProfiles(voiceOptions); ++ const messageStudioDefaults = createMessageStudioDefaultTtsProfiles(voiceOptions); + const custom = createTextToSpeechProfile({ + emotions: [ + createTextToSpeechProfileEmotion({ +@@ -91,9 +93,13 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt + assert.deepEqual(defaults[0].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); + assert.deepEqual(defaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); + assert.deepEqual(defaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); ++ assert.deepEqual(messageStudioDefaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Calm", "Urgent"]); ++ assert.deepEqual(messageStudioDefaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Whisper", "Robot"]); + assert.equal(defaults[0].emotions.find((emotion) => emotion.emotion === "neutral").messagePartsUsageCount, 1); + assert.deepEqual(options, [{ + active: true, ++ age: "any", ++ ageFilter: "any", + emotionSettings: [{ + emotion: "urgent", + emotionLabel: "Urgent", +@@ -103,10 +109,12 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt + ssmlLikePreset: "whisper-ish", + volume: 0.8, + }], ++ gender: "neutral", + key: "custom-profile", + language: "en-US", + name: "Custom Profile", + providerKey: "browser-speech", ++ voice: "test-voice", + voiceName: "Test Voice", + }]); + }); +diff --git a/toolbox/messages/messages.js b/toolbox/messages/messages.js +index 01d26e441..72c339f58 100644 +--- a/toolbox/messages/messages.js ++++ b/toolbox/messages/messages.js +@@ -1,3 +1,7 @@ ++import { ++ createMessageStudioDefaultTtsProfiles, ++ createMessageStudioTtsProfileOptions, ++} from "../text-to-speech/text2speech.js"; + import { + createMessage, + createMessageSegment, +@@ -21,6 +25,11 @@ const TTS_PROVIDER_REGISTRY = Object.freeze({ + "openai": Object.freeze({ activeRuntime: false, label: "OpenAI", requiresConfig: true }), + "polly": Object.freeze({ activeRuntime: false, label: "Polly", requiresConfig: true }), + }); ++const MESSAGE_STUDIO_TTS_PROFILE_CONTRACT = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) ++ .map((profile) => Object.freeze({ ++ ...profile, ++ emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), ++ }))); + + const elements = { + addMessage: document.querySelector("[data-messages-add-row]"), +@@ -190,16 +199,55 @@ function showCreatorSafeFailure(message) { + setText(elements.log, safeMessage); + } + +-function activeEmotionProfiles() { +- return state.emotionProfiles.filter((profile) => profile.active); ++function normalizedLookupKey(value) { ++ return String(value || "").trim().toLowerCase(); + } + +-function activeVoiceProfiles() { +- return state.voiceProfiles.filter((profile) => profile.active); ++function apiTtsProfileByContractName(apiProfiles = []) { ++ return new Map(apiProfiles.map((profile) => [normalizedLookupKey(profile.name), profile])); ++} ++ ++function apiEmotionSettingForContract(apiProfile, contractSetting) { ++ const settings = Array.isArray(apiProfile?.emotionSettings) ? apiProfile.emotionSettings : []; ++ const label = normalizedLookupKey(contractSetting.emotionLabel || contractSetting.name || contractSetting.emotion); ++ const emotion = normalizedLookupKey(contractSetting.emotion); ++ return settings.find((setting) => normalizedLookupKey(setting.emotionLabel || setting.name) === label) ++ || settings.find((setting) => normalizedLookupKey(setting.emotion) === emotion) ++ || null; ++} ++ ++function messageStudioTtsProfilesFromContract(apiProfiles = []) { ++ const apiByName = apiTtsProfileByContractName(apiProfiles); ++ return MESSAGE_STUDIO_TTS_PROFILE_CONTRACT.map((contractProfile) => { ++ const apiProfile = apiByName.get(normalizedLookupKey(contractProfile.name)) || null; ++ return { ++ ...contractProfile, ++ active: apiProfile ? apiProfile.active !== false : contractProfile.active !== false, ++ age: contractProfile.age || apiProfile?.age || "", ++ ageFilter: contractProfile.ageFilter || apiProfile?.ageFilter || apiProfile?.age || "", ++ emotionSettings: contractProfile.emotionSettings ++ .map((contractSetting) => { ++ const apiSetting = apiEmotionSettingForContract(apiProfile, contractSetting); ++ const emotionProfile = emotionProfileByLabel(contractSetting.emotionLabel || apiSetting?.emotionLabel || contractSetting.emotion); ++ return { ++ ...contractSetting, ++ active: apiSetting ? apiSetting.active !== false : contractSetting.active !== false, ++ key: emotionProfile?.key || apiSetting?.key || contractSetting.key, ++ }; ++ }) ++ .filter((setting) => setting.key && setting.active !== false), ++ gender: contractProfile.gender || apiProfile?.gender || "", ++ key: apiProfile?.key || contractProfile.key, ++ language: contractProfile.language || apiProfile?.language || "", ++ providerKey: contractProfile.providerKey || apiProfile?.providerKey || "browser-speech", ++ voice: contractProfile.voice || apiProfile?.voice || apiProfile?.voiceName || "", ++ voiceName: contractProfile.voiceName || apiProfile?.voiceName || "", ++ }; ++ }); + } + +-function emotionProfileByKey(profileKey) { +- return state.emotionProfiles.find((profile) => profile.key === profileKey) || null; ++function activeVoiceProfiles() { ++ return state.voiceProfiles.filter((profile) => profile.active); + } + + function voiceProfileByKey(profileKey) { +@@ -210,15 +258,6 @@ function ttsProfileByKey(profileKey) { + return voiceProfileByKey(profileKey); + } + +-function selectOptionsWithCurrent(currentKey) { +- const active = activeEmotionProfiles(); +- const current = emotionProfileByKey(currentKey); +- if (current && !active.some((profile) => profile.key === current.key)) { +- return [...active, current]; +- } +- return active; +-} +- + function voiceOptionsWithCurrent(currentKey) { + const active = activeVoiceProfiles(); + const current = voiceProfileByKey(currentKey); +@@ -228,23 +267,16 @@ function voiceOptionsWithCurrent(currentKey) { + return active; + } + +-function emotionOptionsForTtsProfile(profileKey, currentKey = "") { ++function emotionOptionsForTtsProfile(profileKey) { + const profile = ttsProfileByKey(profileKey); + const settings = Array.isArray(profile?.emotionSettings) ? profile.emotionSettings : []; +- const options = settings ++ return settings + .filter((setting) => setting.active !== false) + .map((setting) => ({ + key: setting.key || emotionProfileByLabel(setting.emotionLabel || setting.emotion)?.key || setting.emotion, + name: setting.emotionLabel || setting.name || setting.emotion, + })) + .filter((setting) => setting.key && setting.name); +- if (currentKey && !options.some((option) => option.key === currentKey)) { +- const current = emotionProfileByKey(currentKey); +- if (current) { +- options.push({ key: current.key, name: current.name }); +- } +- } +- return options.length ? options : selectOptionsWithCurrent(currentKey); + } + + function emotionProfileByLabel(label) { +@@ -263,8 +295,9 @@ function emotionSettingForKey(profileKey, emotionKey) { + || null; + } + +-function defaultEmotionKeyForTtsProfile(profileKey) { +- return emotionOptionsForTtsProfile(profileKey)[0]?.key || ""; ++function profileEmotionKeyOrDefault(profileKey, currentKey = "") { ++ const options = emotionOptionsForTtsProfile(profileKey); ++ return options.some((option) => option.key === currentKey) ? currentKey : options[0]?.key || ""; + } + + function selectedMessage() { +@@ -716,7 +749,7 @@ function createMessageSegmentEditRow(messageKey, segment = null) { + emotionCell.append(createSelect( + segment?.emotionProfileKey || "", + "segmentEmotion", +- emotionOptionsForTtsProfile(message?.voiceProfileKey || segment?.voiceProfileKey || "", segment?.emotionProfileKey || ""), ++ emotionOptionsForTtsProfile(message?.voiceProfileKey || segment?.voiceProfileKey || ""), + "Select emotion", + "Sentence emotion", + )); +@@ -911,9 +944,10 @@ function messageValues(key) { + const existing = state.messages.find((message) => message.key === key) || null; + const name = editorValue(root, "[data-message-name]"); + const voiceProfileKey = editorValue(root, "[data-message-tts-profile]"); ++ const emotionProfileKey = profileEmotionKeyOrDefault(voiceProfileKey, existing?.emotionProfileKey || ""); + return { + active: existing ? existing.active : true, +- emotionProfileKey: existing?.emotionProfileKey || defaultEmotionKeyForTtsProfile(voiceProfileKey), ++ emotionProfileKey, + messageText: existing?.messageText || name, + name, + notes: existing?.notes || "", +@@ -994,7 +1028,7 @@ async function loadAll() { + state.emotionProfiles = emotionPayload.emotionProfiles || []; + state.messages = messagesPayload.messages || []; + state.segments = segmentsPayload.segments || []; +- state.voiceProfiles = voicePayload.ttsProfiles || []; ++ state.voiceProfiles = messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || []); + if (state.selectedMessageKey && !state.messages.some((message) => message.key === state.selectedMessageKey)) { + state.selectedMessageKey = ""; + } +diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js +index c44c1d406..d98085336 100644 +--- a/toolbox/text-to-speech/text2speech.js ++++ b/toolbox/text-to-speech/text2speech.js +@@ -88,7 +88,8 @@ const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ + Object.freeze({ label: "Calm", value: "calm" }), + Object.freeze({ label: "Urgent", value: "urgent" }), + Object.freeze({ label: "Whisper", value: "whisper" }), +- Object.freeze({ label: "Excited", value: "excited" }) ++ Object.freeze({ label: "Excited", value: "excited" }), ++ Object.freeze({ label: "Robot", value: "robot" }) + ]); + + function boundedNumber(value, { fallback, max, min, value: defaultValue }) { +@@ -279,11 +280,45 @@ function createDefaultTextToSpeechProfiles(voiceOptions = []) { + ]; + } + ++function createMessageStudioDefaultTtsProfiles(voiceOptions = []) { ++ const [balancedProfile, manProfile, womanProfile] = createDefaultTextToSpeechProfiles(voiceOptions); ++ const withStudioEmotions = (profile, emotions) => createTextToSpeechProfile({ ++ active: profile.active, ++ age: profile.age, ++ emotions, ++ gender: profile.gender, ++ id: profile.id, ++ language: profile.language, ++ messageStudioUsageCount: profile.messageStudioUsageCount, ++ name: profile.name, ++ voice: profile.voice, ++ voiceName: profile.voiceName ++ }); ++ ++ return [ ++ withStudioEmotions(balancedProfile, [ ++ createTextToSpeechProfileEmotion({ emotion: "calm", messagePartsUsageCount: 1 }), ++ createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), ++ ]), ++ withStudioEmotions(manProfile, [ ++ createTextToSpeechProfileEmotion({ emotion: "neutral" }), ++ createTextToSpeechProfileEmotion({ emotion: "calm" }), ++ createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), ++ ]), ++ withStudioEmotions(womanProfile, [ ++ createTextToSpeechProfileEmotion({ emotion: "whisper", pitch: 0.95, rate: 0.9, volume: 0.55 }), ++ createTextToSpeechProfileEmotion({ emotion: "robot", pitch: 0.82, rate: 0.92, volume: 0.9 }), ++ ]) ++ ]; ++} ++ + function createMessageStudioTtsProfileOptions(profiles = []) { + return profiles + .filter((profile) => profile?.active !== false) + .map((profile) => ({ + active: true, ++ age: profile.age, ++ ageFilter: profile.age, + emotionSettings: Array.isArray(profile.emotions) + ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ + emotion: emotion.emotion, +@@ -295,10 +330,12 @@ function createMessageStudioTtsProfileOptions(profiles = []) { + volume: emotion.volume + })) + : [], ++ gender: profile.gender, + key: profile.id, + language: profile.language, + name: profile.name, + providerKey: profile.providerKey || "browser-speech", ++ voice: profile.voice, + voiceName: profile.voiceName || profile.voice || "" + })); + } +@@ -1206,7 +1243,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + }; + } + +-if (typeof document !== "undefined") { ++if (typeof document !== "undefined" && document.querySelector("[data-tts-profile-table]")) { + initializeTextToSpeechTool(document); + } + +@@ -1218,6 +1255,7 @@ export { + TTS_PROVIDER_ADAPTER_PLAN, + createEmotionProfile, + createDefaultTextToSpeechProfiles, ++ createMessageStudioDefaultTtsProfiles, + createMessageStudioTtsProfileOptions, + createSpeechPreviewRequest, + createTextToSpeechProfile, diff --git a/src/dev-runtime/messages/messages-postgres-service.mjs b/src/dev-runtime/messages/messages-postgres-service.mjs index 6db58ed91..a326dc9ae 100644 --- a/src/dev-runtime/messages/messages-postgres-service.mjs +++ b/src/dev-runtime/messages/messages-postgres-service.mjs @@ -1,4 +1,8 @@ import { randomBytes } from "node:crypto"; +import { + createMessageStudioDefaultTtsProfiles, + createMessageStudioTtsProfileOptions, +} from "../../../toolbox/text-to-speech/text2speech.js"; import { createPostgresConnectionClient } from "../persistence/postgres-connection-client.mjs"; import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; @@ -14,6 +18,7 @@ const SEED_CATEGORY_NAMES = Object.freeze([ "Notification", ]); const SEED_EMOTION_PROFILES = Object.freeze([ + Object.freeze({ description: "Balanced spoken delivery for general narration or dialog.", name: "Neutral", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), Object.freeze({ description: "Neutral spoken delivery for general narration or dialog.", name: "Calm", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), Object.freeze({ description: "Fast, alert delivery for warnings and immediate danger.", name: "Urgent", pauseAfterMs: 80, pauseBeforeMs: 0, pitch: 1.08, rate: 1.15, volume: 1 }), Object.freeze({ description: "Quiet delivery for secret, stealth, or intimate lines.", name: "Whisper", pauseAfterMs: 180, pauseBeforeMs: 80, pitch: 0.95, rate: 0.9, volume: 0.55 }), @@ -21,28 +26,37 @@ const SEED_EMOTION_PROFILES = Object.freeze([ Object.freeze({ description: "Bright delivery for reveals, wins, and high-energy moments.", name: "Excited", pauseAfterMs: 100, pauseBeforeMs: 0, pitch: 1.12, rate: 1.12, volume: 1 }), Object.freeze({ description: "Soft delivery for loss, regret, or reflective moments.", name: "Sad", pauseAfterMs: 220, pauseBeforeMs: 100, pitch: 0.9, rate: 0.85, volume: 0.8 }), Object.freeze({ description: "Measured delivery for suspense, hidden lore, or strange events.", name: "Mysterious", pauseAfterMs: 260, pauseBeforeMs: 120, pitch: 0.92, rate: 0.88, volume: 0.85 }), + Object.freeze({ description: "Synthetic delivery for mechanical or artificial characters.", name: "Robot", pauseAfterMs: 120, pauseBeforeMs: 40, pitch: 0.82, rate: 0.92, volume: 0.9 }), ]); -const SEED_TTS_PROFILES = Object.freeze([ - Object.freeze({ - description: "Balanced local browser playback option until authored TTS profiles are available.", - language: "en-US", - name: "Default Balanced TTS Profile", - pitch: 1, - providerKey: "browser-speech", - rate: 1, - voiceName: "", - volume: 1, - }), - Object.freeze({ - description: "Narration-focused preview configuration for future spoken story text.", - language: "en-US", - name: "Narration Preview", - pitch: 0.95, - providerKey: "browser-speech", - rate: 0.9, - voiceName: "", - volume: 0.9, - }), +const MESSAGE_STUDIO_TTS_PROFILE_OPTIONS = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) + .map((profile) => Object.freeze({ + ...profile, + emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), + }))); +const SEED_TTS_PROFILES = Object.freeze(MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.map((profile) => Object.freeze({ + description: `${profile.name} from Text To Speech profile ownership.`, + language: profile.language || "en-US", + name: profile.name, + pitch: 1, + providerKey: profile.providerKey || "browser-speech", + rate: 1, + voiceName: profile.voiceName || "Default browser voice", + volume: 1, +}))); +const SUPPORTED_TTS_PROVIDER_KEYS = Object.freeze([ + "browser-speech", + "elevenlabs", + "openai", + "azure", + "polly", +]); +const ACTIVE_PUBLISH_TTS_PROVIDER_KEYS = Object.freeze([ + "browser-speech", +]); +const MESSAGE_EVENT_ACTION_TYPES = Object.freeze([ + Object.freeze({ key: "show-message", label: "Show Message", requiresMessage: true }), + Object.freeze({ key: "speak-message", label: "Speak Message", requiresMessage: true }), + Object.freeze({ key: "wait-for-continue", label: "Wait For Continue", requiresMessage: false }), ]); const MESSAGES_POSTGRES_SCHEMA_SQL = ` @@ -72,20 +86,6 @@ CREATE TABLE IF NOT EXISTS messages_emotion_profiles ( "updatedBy" text NOT NULL REFERENCES users(key) ); -CREATE TABLE IF NOT EXISTS messages_records ( - key text PRIMARY KEY, - "name" text NOT NULL, - "categoryKey" text NOT NULL REFERENCES messages_categories(key), - "emotionProfileKey" text NOT NULL REFERENCES messages_emotion_profiles(key), - "messageText" text NOT NULL, - "notes" text NOT NULL DEFAULT '', - "active" boolean NOT NULL DEFAULT true, - "createdAt" timestamptz NOT NULL DEFAULT now(), - "updatedAt" timestamptz NOT NULL DEFAULT now(), - "createdBy" text NOT NULL REFERENCES users(key), - "updatedBy" text NOT NULL REFERENCES users(key) -); - CREATE TABLE IF NOT EXISTS messages_tts_profiles ( key text PRIMARY KEY, "name" text NOT NULL UNIQUE, @@ -103,10 +103,26 @@ CREATE TABLE IF NOT EXISTS messages_tts_profiles ( "updatedBy" text NOT NULL REFERENCES users(key) ); +CREATE TABLE IF NOT EXISTS messages_records ( + key text PRIMARY KEY, + "name" text NOT NULL, + "categoryKey" text NOT NULL REFERENCES messages_categories(key), + "emotionProfileKey" text NOT NULL REFERENCES messages_emotion_profiles(key), + "voiceProfileKey" text NOT NULL REFERENCES messages_tts_profiles(key), + "messageText" text NOT NULL, + "notes" text NOT NULL DEFAULT '', + "active" boolean NOT NULL DEFAULT true, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + "createdBy" text NOT NULL REFERENCES users(key), + "updatedBy" text NOT NULL REFERENCES users(key) +); + CREATE TABLE IF NOT EXISTS messages_segments ( key text PRIMARY KEY, "messageKey" text NOT NULL REFERENCES messages_records(key), "emotionProfileKey" text NOT NULL REFERENCES messages_emotion_profiles(key), + "voiceProfileKey" text NOT NULL REFERENCES messages_tts_profiles(key), "segmentText" text NOT NULL, "displayOrder" integer NOT NULL, "active" boolean NOT NULL DEFAULT true, @@ -116,15 +132,36 @@ CREATE TABLE IF NOT EXISTS messages_segments ( "updatedBy" text NOT NULL REFERENCES users(key) ); +CREATE TABLE IF NOT EXISTS messages_event_actions ( + key text PRIMARY KEY, + "name" text NOT NULL, + "actionType" text NOT NULL, + "messageKey" text REFERENCES messages_records(key), + "active" boolean NOT NULL DEFAULT true, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + "createdBy" text NOT NULL REFERENCES users(key), + "updatedBy" text NOT NULL REFERENCES users(key) +); + +ALTER TABLE messages_records ADD COLUMN IF NOT EXISTS "voiceProfileKey" text REFERENCES messages_tts_profiles(key); +ALTER TABLE messages_segments ADD COLUMN IF NOT EXISTS "voiceProfileKey" text REFERENCES messages_tts_profiles(key); + CREATE INDEX IF NOT EXISTS idx_messages_records_categorykey ON messages_records ("categoryKey"); CREATE INDEX IF NOT EXISTS idx_messages_records_emotionprofilekey ON messages_records ("emotionProfileKey"); +CREATE INDEX IF NOT EXISTS idx_messages_records_voiceprofilekey ON messages_records ("voiceProfileKey"); CREATE INDEX IF NOT EXISTS idx_messages_records_createdby ON messages_records ("createdBy"); CREATE INDEX IF NOT EXISTS idx_messages_records_updatedby ON messages_records ("updatedBy"); CREATE INDEX IF NOT EXISTS idx_messages_segments_messagekey ON messages_segments ("messageKey"); CREATE INDEX IF NOT EXISTS idx_messages_segments_emotionprofilekey ON messages_segments ("emotionProfileKey"); +CREATE INDEX IF NOT EXISTS idx_messages_segments_voiceprofilekey ON messages_segments ("voiceProfileKey"); CREATE INDEX IF NOT EXISTS idx_messages_segments_order ON messages_segments ("messageKey", "displayOrder"); CREATE INDEX IF NOT EXISTS idx_messages_segments_createdby ON messages_segments ("createdBy"); CREATE INDEX IF NOT EXISTS idx_messages_segments_updatedby ON messages_segments ("updatedBy"); +CREATE INDEX IF NOT EXISTS idx_messages_event_actions_actiontype ON messages_event_actions ("actionType"); +CREATE INDEX IF NOT EXISTS idx_messages_event_actions_messagekey ON messages_event_actions ("messageKey"); +CREATE INDEX IF NOT EXISTS idx_messages_event_actions_createdby ON messages_event_actions ("createdBy"); +CREATE INDEX IF NOT EXISTS idx_messages_event_actions_updatedby ON messages_event_actions ("updatedBy"); CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_providerkey ON messages_tts_profiles ("providerKey"); CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_createdby ON messages_tts_profiles ("createdBy"); CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_updatedby ON messages_tts_profiles ("updatedBy"); @@ -177,6 +214,41 @@ function normalizeNumber(value, fallback) { return Number.isFinite(numberValue) ? numberValue : fallback; } +function normalizeRequiredNumber(value, label) { + if (value === undefined || value === null || String(value).trim() === "") { + throw httpError(`${label} is required.`); + } + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + throw httpError(`${label} must be a number.`); + } + return numberValue; +} + +function normalizeEditableNumber(value, fallback, label) { + return value === undefined ? fallback : normalizeRequiredNumber(value, label); +} + +function normalizeTtsProviderKey(value) { + const providerKey = normalizeName(value, "Voice profile provider"); + if (!SUPPORTED_TTS_PROVIDER_KEYS.includes(providerKey)) { + throw httpError("Voice profile provider is not supported."); + } + return providerKey; +} + +function eventActionTypeDefinition(actionType) { + return MESSAGE_EVENT_ACTION_TYPES.find((candidate) => candidate.key === actionType) || null; +} + +function normalizeEventActionType(value) { + const actionType = normalizeName(value, "Event action"); + if (!eventActionTypeDefinition(actionType)) { + throw httpError("Event action is not supported."); + } + return actionType; +} + function emotionSettingKey(value) { return normalizeText(value) .trim() @@ -214,7 +286,7 @@ function queryForKey(key) { return `select=*&key=eq.${encodeURIComponent(key)}`; } -function messageRecordFromRow(row, { categoryName = "", emotionProfileName = "" } = {}) { +function messageRecordFromRow(row, { categoryName = "", emotionProfileName = "", voiceProfileName = "" } = {}) { return { active: activeFromDatabase(row.active), categoryKey: row.categoryKey, @@ -229,6 +301,8 @@ function messageRecordFromRow(row, { categoryName = "", emotionProfileName = "" notes: row.notes || "", updatedAt: row.updatedAt, updatedBy: row.updatedBy, + voiceProfileKey: row.voiceProfileKey || "", + voiceProfileName, }; } @@ -275,6 +349,7 @@ function ttsEmotionSettingFromEmotionProfile(profile) { active: profile.active !== false, emotion: emotionSettingKey(profile.name), emotionLabel: profile.name, + key: profile.key, pitch: Number(profile.pitch), rate: Number(profile.rate), ssmlLikePreset: "normal", @@ -282,13 +357,54 @@ function ttsEmotionSettingFromEmotionProfile(profile) { }; } +function messageStudioTtsProfileOption(row) { + const rowName = normalizeText(row?.name).trim().toLowerCase(); + const rowKey = normalizeText(row?.key).trim(); + return MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.find((profile) => { + return profile.key === rowKey || normalizeText(profile.name).trim().toLowerCase() === rowName; + }) || null; +} + +function emotionSettingsForTtsProfileRow(row, emotionRows = []) { + const option = messageStudioTtsProfileOption(row); + if (!option) { + return []; + } + const activeEmotionProfiles = emotionRows + .map((profileRow) => emotionProfileFromRow(profileRow)) + .filter((profile) => profile.active !== false); + const byLabel = new Map(activeEmotionProfiles.map((profile) => [normalizeText(profile.name).trim().toLowerCase(), profile])); + const byEmotion = new Map(activeEmotionProfiles.map((profile) => [emotionSettingKey(profile.name), profile])); + return option.emotionSettings + .map((setting) => { + const emotionProfile = byLabel.get(normalizeText(setting.emotionLabel).trim().toLowerCase()) + || byEmotion.get(emotionSettingKey(setting.emotion)) + || null; + if (!emotionProfile) { + return null; + } + return { + ...ttsEmotionSettingFromEmotionProfile(emotionProfile), + pitch: Number(setting.pitch), + rate: Number(setting.rate), + ssmlLikePreset: setting.ssmlLikePreset || "normal", + volume: Number(setting.volume), + }; + }) + .filter(Boolean); +} + function ttsProfileFromRow(row, emotionSettings = []) { + const profileOption = messageStudioTtsProfileOption(row); return { active: activeFromDatabase(row.active), + age: profileOption?.age || "", + ageFilter: profileOption?.ageFilter || profileOption?.age || "", createdAt: row.createdAt, createdBy: row.createdBy, description: row.description || "", emotionSettings, + gender: profileOption?.gender || "", key: row.key, language: row.language, name: row.name, @@ -298,12 +414,13 @@ function ttsProfileFromRow(row, emotionSettings = []) { status: activeFromDatabase(row.active) ? "Active" : "Inactive", updatedAt: row.updatedAt, updatedBy: row.updatedBy, + voice: profileOption?.voice || row.voiceName || "", voiceName: row.voiceName || "", volume: Number(row.volume), }; } -function messageSegmentFromRow(row, { emotionProfileName = "", messageName = "" } = {}) { +function messageSegmentFromRow(row, { emotionProfileName = "", messageName = "", voiceProfileName = "" } = {}) { return { active: activeFromDatabase(row.active), createdAt: row.createdAt, @@ -317,6 +434,36 @@ function messageSegmentFromRow(row, { emotionProfileName = "", messageName = "" segmentText: row.segmentText, updatedAt: row.updatedAt, updatedBy: row.updatedBy, + voiceProfileKey: row.voiceProfileKey || "", + voiceProfileName, + }; +} + +function messageEventActionFromRow(row, { messageName = "" } = {}) { + const actionType = row.actionType || ""; + return { + actionLabel: eventActionTypeDefinition(actionType)?.label || actionType, + actionType, + active: activeFromDatabase(row.active), + createdAt: row.createdAt, + createdBy: row.createdBy, + key: row.key, + messageKey: row.messageKey || "", + messageName, + name: row.name, + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, + }; +} + +function publishValidationIssue({ code, field, message, targetKey = "", targetName = "", targetType }) { + return { + code, + field, + message, + targetKey: normalizeText(targetKey), + targetName: normalizeText(targetName), + targetType, }; } @@ -385,6 +532,14 @@ export class MessagesPostgresService { return cloneRows(rows)[0] || null; } + async deleteRow(tableName, key) { + const rows = await this.client().requestTable(tableName, { + method: "DELETE", + query: queryForKey(key), + }); + return cloneRows(rows)[0] || null; + } + async rowByKey(tableName, key) { const rows = await this.client().requestTable(tableName, { method: "GET", query: queryForKey(key) }); return cloneRows(rows)[0] || null; @@ -421,6 +576,7 @@ export class MessagesPostgresService { }, { skipEnsure: true }); } } + await this.backfillDefaultVoiceProfileReferences(); } persistenceSummary() { @@ -592,11 +748,11 @@ export class MessagesPostgresService { name: normalizeName(input.name, "Emotion profile name"), pauseAfterMs: normalizeInteger(input.pauseAfterMs, 0), pauseBeforeMs: normalizeInteger(input.pauseBeforeMs, 0), - pitch: normalizeNumber(input.pitch, 1), - rate: normalizeNumber(input.rate, 1), + pitch: normalizeRequiredNumber(input.pitch, "Emotion profile pitch"), + rate: normalizeRequiredNumber(input.rate, "Emotion profile rate"), updatedAt: now, updatedBy: actor, - volume: normalizeNumber(input.volume, 1), + volume: normalizeRequiredNumber(input.volume, "Emotion profile volume"), }); const row = await this.rowByKey("messages_emotion_profiles", key); return emotionProfileFromRow(row, await this.emotionProfileUsage(key)); @@ -632,35 +788,31 @@ export class MessagesPostgresService { name, pauseAfterMs: normalizeInteger(input.pauseAfterMs, existing.pauseAfterMs), pauseBeforeMs: normalizeInteger(input.pauseBeforeMs, existing.pauseBeforeMs), - pitch: normalizeNumber(input.pitch, existing.pitch), - rate: normalizeNumber(input.rate, existing.rate), + pitch: normalizeEditableNumber(input.pitch, existing.pitch, "Emotion profile pitch"), + rate: normalizeEditableNumber(input.rate, existing.rate, "Emotion profile rate"), updatedAt: timestamp(), updatedBy: normalizeActorKey(actorKey), - volume: normalizeNumber(input.volume, existing.volume), + volume: normalizeEditableNumber(input.volume, existing.volume, "Emotion profile volume"), }); return this.getEmotionProfile(key); } async listTtsProfiles() { await this.ensureReady(); - const emotionSettings = (await this.tableRows("messages_emotion_profiles")) - .map((profileRow) => emotionProfileFromRow(profileRow)) - .filter((profile) => profile.active !== false) - .map(ttsEmotionSettingFromEmotionProfile); - return (await this.tableRows("messages_tts_profiles")).sort(compareName).map((row) => ttsProfileFromRow(row, emotionSettings)); + const emotionRows = await this.tableRows("messages_emotion_profiles"); + return (await this.tableRows("messages_tts_profiles")) + .sort(compareName) + .map((row) => ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows))); } async getTtsProfile(key) { await this.ensureReady(); const row = await this.rowByKey("messages_tts_profiles", key); if (!row) { - throw httpError("TTS profile was not found.", 404); + throw httpError("Voice profile was not found.", 404); } - const emotionSettings = (await this.tableRows("messages_emotion_profiles")) - .map((profileRow) => emotionProfileFromRow(profileRow)) - .filter((profile) => profile.active !== false) - .map(ttsEmotionSettingFromEmotionProfile); - return ttsProfileFromRow(row, emotionSettings); + const emotionRows = await this.tableRows("messages_emotion_profiles"); + return ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows)); } async findTtsProfileByNameRaw(name) { @@ -677,17 +829,46 @@ export class MessagesPostgresService { return row ? ttsProfileFromRow(row) : null; } + async defaultVoiceProfileKeyRaw() { + const defaultProfile = await this.findTtsProfileByNameRaw("Default Balanced Profile"); + if (defaultProfile) { + return defaultProfile.key; + } + const fallback = (await this.tableRows("messages_tts_profiles")).sort(compareName)[0]; + if (!fallback) { + throw httpError("Voice profile seed is unavailable. Restart the Local API runtime."); + } + return fallback.key; + } + + async backfillDefaultVoiceProfileReferences() { + const voiceProfileKey = await this.defaultVoiceProfileKeyRaw(); + for (const tableName of ["messages_records", "messages_segments"]) { + const rows = await this.tableRows(tableName); + for (const row of rows) { + if (normalizeText(row.voiceProfileKey).trim()) { + continue; + } + await this.patchRow(tableName, row.key, { + updatedAt: timestamp(), + updatedBy: normalizeActorKey(row.updatedBy), + voiceProfileKey, + }); + } + } + } + normalizeTtsProfileInput(input = {}, existing = null) { - const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "TTS profile name"); + const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "Voice profile name"); return { active: normalizeActive(input.active, existing ? existing.active : true), description: input.description === undefined && existing ? existing.description : normalizeText(input.description), - language: input.language === undefined && existing ? existing.language : normalizeName(input.language, "TTS profile language"), + language: input.language === undefined && existing ? existing.language : normalizeName(input.language, "Voice profile language"), name, pitch: normalizeNumber(input.pitch, existing ? existing.pitch : 1), - providerKey: input.providerKey === undefined && existing ? existing.providerKey : normalizeName(input.providerKey, "TTS provider key"), + providerKey: input.providerKey === undefined && existing ? existing.providerKey : normalizeTtsProviderKey(input.providerKey), rate: normalizeNumber(input.rate, existing ? existing.rate : 1), - voiceName: input.voiceName === undefined && existing ? existing.voiceName : normalizeText(input.voiceName), + voiceName: input.voiceName === undefined && existing ? existing.voiceName : normalizeName(input.voiceName, "Voice profile voice name"), volume: normalizeNumber(input.volume, existing ? existing.volume : 1), }; } @@ -717,18 +898,15 @@ export class MessagesPostgresService { volume: values.volume, }); const row = await this.rowByKey("messages_tts_profiles", key); - const emotionSettings = (await this.tableRows("messages_emotion_profiles")) - .map((profileRow) => emotionProfileFromRow(profileRow)) - .filter((profile) => profile.active !== false) - .map(ttsEmotionSettingFromEmotionProfile); - return ttsProfileFromRow(row, emotionSettings); + const emotionRows = await this.tableRows("messages_emotion_profiles"); + return ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows)); } async createTtsProfile(input = {}, actorKey = "") { const values = this.normalizeTtsProfileInput(input); const existing = await this.findTtsProfileByName(values.name); if (existing) { - throw httpError(`TTS profile ${values.name} already exists.`); + throw httpError(`Voice profile ${values.name} already exists.`); } return this.insertTtsProfile({ ...values, @@ -741,7 +919,7 @@ export class MessagesPostgresService { const values = this.normalizeTtsProfileInput(input, existing); const duplicate = await this.findTtsProfileByName(values.name); if (duplicate && duplicate.key !== key) { - throw httpError(`TTS profile ${values.name} already exists.`); + throw httpError(`Voice profile ${values.name} already exists.`); } await this.patchRow("messages_tts_profiles", key, { active: values.active, @@ -763,11 +941,13 @@ export class MessagesPostgresService { await this.ensureReady(); const categories = new Map((await this.tableRows("messages_categories")).map((row) => [row.key, row.name])); const emotions = new Map((await this.tableRows("messages_emotion_profiles")).map((row) => [row.key, row.name])); + const voices = new Map((await this.tableRows("messages_tts_profiles")).map((row) => [row.key, row.name])); return (await this.tableRows("messages_records")) .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)) || compareName(left, right)) .map((row) => messageRecordFromRow(row, { categoryName: categories.get(row.categoryKey) || "", emotionProfileName: emotions.get(row.emotionProfileKey) || "", + voiceProfileName: voices.get(row.voiceProfileKey) || "", })); } @@ -779,9 +959,11 @@ export class MessagesPostgresService { } const category = await this.rowByKey("messages_categories", row.categoryKey); const emotion = await this.rowByKey("messages_emotion_profiles", row.emotionProfileKey); + const voice = await this.rowByKey("messages_tts_profiles", row.voiceProfileKey); return messageRecordFromRow(row, { categoryName: category?.name || "", emotionProfileName: emotion?.name || "", + voiceProfileName: voice?.name || "", }); } @@ -810,20 +992,33 @@ export class MessagesPostgresService { return profile; } + async assertActiveVoiceProfile(key) { + const profile = await this.getTtsProfile(key); + if (!profile.active) { + throw httpError("Voice profile is inactive. Choose an active voice profile before saving a message."); + } + return profile; + } + async normalizeMessageInput(input = {}, existing = null) { const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "Message name"); const categoryKey = normalizeText(input.categoryKey === undefined && existing ? existing.categoryKey : input.categoryKey).trim() || await this.defaultMessageCategoryKey(); const emotionProfileKey = normalizeText(input.emotionProfileKey === undefined && existing ? existing.emotionProfileKey : input.emotionProfileKey).trim(); + const voiceProfileKey = normalizeText(input.voiceProfileKey === undefined && existing ? existing.voiceProfileKey : input.voiceProfileKey).trim(); const messageText = input.messageText === undefined && existing ? existing.messageText : normalizeText(input.messageText); if (!emotionProfileKey) { throw httpError("Emotion profile is required."); } + if (!voiceProfileKey) { + throw httpError("Voice profile is required."); + } if (!messageText.trim()) { throw httpError("Message text is required."); } await this.assertActiveCategory(categoryKey); await this.assertActiveEmotionProfile(emotionProfileKey); + await this.assertActiveVoiceProfile(voiceProfileKey); return { active: normalizeActive(input.active, existing ? existing.active : true), categoryKey, @@ -831,6 +1026,7 @@ export class MessagesPostgresService { messageText, name, notes: input.notes === undefined && existing ? existing.notes : normalizeText(input.notes), + voiceProfileKey, }; } @@ -852,6 +1048,7 @@ export class MessagesPostgresService { notes: values.notes, updatedAt: now, updatedBy: actor, + voiceProfileKey: values.voiceProfileKey, }); return this.getMessage(key); } @@ -868,14 +1065,27 @@ export class MessagesPostgresService { notes: values.notes, updatedAt: timestamp(), updatedBy: normalizeActorKey(actorKey), + voiceProfileKey: values.voiceProfileKey, }); return this.getMessage(key); } + async deleteMessage(key) { + const existing = await this.getMessage(key); + const referenced = (await this.tableRows("messages_segments")) + .some((segment) => segment.messageKey === existing.key); + if (referenced) { + throw httpError("Message is referenced by message parts. Remove those references before deleting this message.", 409); + } + await this.deleteRow("messages_records", existing.key); + return existing; + } + async listMessageSegments() { await this.ensureReady(); const messages = new Map((await this.tableRows("messages_records")).map((row) => [row.key, row.name])); const emotions = new Map((await this.tableRows("messages_emotion_profiles")).map((row) => [row.key, row.name])); + const voices = new Map((await this.tableRows("messages_tts_profiles")).map((row) => [row.key, row.name])); return (await this.tableRows("messages_segments")) .sort((left, right) => String(left.messageKey).localeCompare(String(right.messageKey)) || Number(left.displayOrder) - Number(right.displayOrder) @@ -884,6 +1094,7 @@ export class MessagesPostgresService { .map((row) => messageSegmentFromRow(row, { emotionProfileName: emotions.get(row.emotionProfileKey) || "", messageName: messages.get(row.messageKey) || "", + voiceProfileName: voices.get(row.voiceProfileKey) || "", })); } @@ -895,15 +1106,18 @@ export class MessagesPostgresService { } const message = await this.rowByKey("messages_records", row.messageKey); const emotion = await this.rowByKey("messages_emotion_profiles", row.emotionProfileKey); + const voice = await this.rowByKey("messages_tts_profiles", row.voiceProfileKey); return messageSegmentFromRow(row, { emotionProfileName: emotion?.name || "", messageName: message?.name || "", + voiceProfileName: voice?.name || "", }); } async normalizeMessageSegmentInput(input = {}, existing = null) { const messageKey = normalizeText(input.messageKey === undefined && existing ? existing.messageKey : input.messageKey).trim(); const emotionProfileKey = normalizeText(input.emotionProfileKey === undefined && existing ? existing.emotionProfileKey : input.emotionProfileKey).trim(); + const voiceProfileKey = normalizeText(input.voiceProfileKey === undefined && existing ? existing.voiceProfileKey : input.voiceProfileKey).trim(); const segmentText = input.segmentText === undefined && existing ? existing.segmentText : normalizeText(input.segmentText); const displayOrder = normalizeRequiredInteger( input.displayOrder === undefined && existing ? existing.displayOrder : input.displayOrder, @@ -915,17 +1129,22 @@ export class MessagesPostgresService { if (!emotionProfileKey) { throw httpError("Emotion profile is required."); } + if (!voiceProfileKey) { + throw httpError("Voice profile is required."); + } if (!segmentText.trim()) { throw httpError("Segment text is required."); } await this.getMessage(messageKey); await this.assertActiveEmotionProfile(emotionProfileKey); + await this.assertActiveVoiceProfile(voiceProfileKey); return { active: normalizeActive(input.active, existing ? existing.active : true), displayOrder, emotionProfileKey, messageKey, segmentText, + voiceProfileKey, }; } @@ -946,6 +1165,7 @@ export class MessagesPostgresService { segmentText: values.segmentText, updatedAt: now, updatedBy: actor, + voiceProfileKey: values.voiceProfileKey, }); return this.getMessageSegment(key); } @@ -961,9 +1181,274 @@ export class MessagesPostgresService { segmentText: values.segmentText, updatedAt: timestamp(), updatedBy: normalizeActorKey(actorKey), + voiceProfileKey: values.voiceProfileKey, }); return this.getMessageSegment(key); } + + async deleteMessageSegment(key) { + const existing = await this.getMessageSegment(key); + await this.deleteRow("messages_segments", existing.key); + return existing; + } + + async listMessageEventActions() { + await this.ensureReady(); + const messages = new Map((await this.tableRows("messages_records")).map((row) => [row.key, row.name])); + return (await this.tableRows("messages_event_actions")) + .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)) || compareName(left, right)) + .map((row) => messageEventActionFromRow(row, { + messageName: messages.get(row.messageKey) || "", + })); + } + + async getMessageEventAction(key) { + await this.ensureReady(); + const row = await this.rowByKey("messages_event_actions", key); + if (!row) { + throw httpError("Message event action was not found.", 404); + } + const message = row.messageKey ? await this.rowByKey("messages_records", row.messageKey) : null; + return messageEventActionFromRow(row, { + messageName: message?.name || "", + }); + } + + async normalizeMessageEventActionInput(input = {}, existing = null) { + const actionType = input.actionType === undefined && existing ? existing.actionType : normalizeEventActionType(input.actionType); + const actionDefinition = eventActionTypeDefinition(actionType); + const rawMessageKey = input.messageKey === undefined && existing ? existing.messageKey : input.messageKey; + const messageKey = normalizeText(rawMessageKey).trim(); + const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "Event action name"); + if (actionDefinition?.requiresMessage && !messageKey) { + throw httpError("Message is required for this event action."); + } + if (!actionDefinition?.requiresMessage && messageKey) { + throw httpError("Message is not used by this event action."); + } + if (messageKey) { + await this.getMessage(messageKey); + } + return { + actionType, + active: normalizeActive(input.active, existing ? existing.active : true), + messageKey, + name, + }; + } + + async createMessageEventAction(input = {}, actorKey = "") { + await this.ensureReady(); + const values = await this.normalizeMessageEventActionInput(input); + const key = createUlid(); + const now = timestamp(); + const actor = normalizeActorKey(actorKey); + await this.upsertRow("messages_event_actions", { + actionType: values.actionType, + active: values.active, + createdAt: now, + createdBy: actor, + key, + messageKey: values.messageKey || null, + name: values.name, + updatedAt: now, + updatedBy: actor, + }); + return this.getMessageEventAction(key); + } + + async updateMessageEventAction(key, input = {}, actorKey = "") { + const existing = await this.getMessageEventAction(key); + const values = await this.normalizeMessageEventActionInput(input, existing); + await this.patchRow("messages_event_actions", key, { + actionType: values.actionType, + active: values.active, + messageKey: values.messageKey || null, + name: values.name, + updatedAt: timestamp(), + updatedBy: normalizeActorKey(actorKey), + }); + return this.getMessageEventAction(key); + } + + async validateMessagePublishConfiguration() { + await this.ensureReady(); + const messages = await this.tableRows("messages_records"); + const segments = await this.tableRows("messages_segments"); + const emotions = new Map((await this.tableRows("messages_emotion_profiles")).map((row) => [row.key, row])); + const voices = new Map((await this.tableRows("messages_tts_profiles")).map((row) => [row.key, row])); + const eventActions = await this.tableRows("messages_event_actions"); + const messagesByKey = new Map(messages.map((row) => [row.key, row])); + const issues = []; + + const addIssue = (issue) => { + issues.push(publishValidationIssue(issue)); + }; + + const validateEmotionReference = (row, targetType, targetName) => { + const emotionProfileKey = normalizeText(row.emotionProfileKey).trim(); + if (!emotionProfileKey) { + addIssue({ + code: "missing-emotion-profile", + field: "emotionProfileKey", + message: "Choose an Emotion Profile before publishing.", + targetKey: row.key, + targetName, + targetType, + }); + return; + } + if (!emotions.has(emotionProfileKey)) { + addIssue({ + code: "broken-reference", + field: "emotionProfileKey", + message: "Choose an existing Emotion Profile before publishing.", + targetKey: row.key, + targetName, + targetType, + }); + } + }; + + const validateVoiceReference = (row, targetType, targetName) => { + const voiceProfileKey = normalizeText(row.voiceProfileKey).trim(); + if (!voiceProfileKey) { + addIssue({ + code: "missing-voice-profile", + field: "voiceProfileKey", + message: "Choose a Voice Profile before publishing.", + targetKey: row.key, + targetName, + targetType, + }); + return; + } + const voiceProfile = voices.get(voiceProfileKey); + if (!voiceProfile) { + addIssue({ + code: "broken-reference", + field: "voiceProfileKey", + message: "Choose an existing Voice Profile before publishing.", + targetKey: row.key, + targetName, + targetType, + }); + return; + } + const providerKey = normalizeText(voiceProfile.providerKey).trim(); + if (!SUPPORTED_TTS_PROVIDER_KEYS.includes(providerKey) || !ACTIVE_PUBLISH_TTS_PROVIDER_KEYS.includes(providerKey)) { + addIssue({ + code: "invalid-provider-assignment", + field: "providerKey", + message: "Use Browser Speech API for publish-ready message voice playback.", + targetKey: row.key, + targetName, + targetType, + }); + } + }; + + messages.forEach((message) => { + const targetName = normalizeText(message.name) || "Message"; + if (!normalizeText(message.messageText).trim()) { + addIssue({ + code: "missing-message-text", + field: "messageText", + message: "Add message text before publishing.", + targetKey: message.key, + targetName, + targetType: "message", + }); + } + validateEmotionReference(message, "message", targetName); + validateVoiceReference(message, "message", targetName); + }); + + segments.forEach((segment) => { + const targetName = segment.displayOrder ? `Part ${segment.displayOrder}` : "Message Part"; + const messageKey = normalizeText(segment.messageKey).trim(); + if (!messageKey || !messagesByKey.has(messageKey)) { + addIssue({ + code: "broken-reference", + field: "messageKey", + message: "Connect this Message Part to an existing Message before publishing.", + targetKey: segment.key, + targetName, + targetType: "message-part", + }); + } + if (!normalizeText(segment.segmentText).trim()) { + addIssue({ + code: "missing-message-text", + field: "segmentText", + message: "Add Message Part text before publishing.", + targetKey: segment.key, + targetName, + targetType: "message-part", + }); + } + validateEmotionReference(segment, "message-part", targetName); + validateVoiceReference(segment, "message-part", targetName); + }); + + eventActions.forEach((eventAction) => { + const actionType = normalizeText(eventAction.actionType).trim(); + const actionDefinition = eventActionTypeDefinition(actionType); + const messageKey = normalizeText(eventAction.messageKey).trim(); + const targetName = normalizeText(eventAction.name) || actionDefinition?.label || "Message Event Action"; + if (!actionDefinition) { + addIssue({ + code: "broken-reference", + field: "actionType", + message: "Choose a supported Message Event Action before publishing.", + targetKey: eventAction.key, + targetName, + targetType: "event-action", + }); + return; + } + if (actionDefinition.requiresMessage && !messageKey) { + addIssue({ + code: "broken-reference", + field: "messageKey", + message: "Choose a Message for this Event Action before publishing.", + targetKey: eventAction.key, + targetName, + targetType: "event-action", + }); + return; + } + if (messageKey && !messagesByKey.has(messageKey)) { + addIssue({ + code: "broken-reference", + field: "messageKey", + message: "Choose an existing Message for this Event Action before publishing.", + targetKey: eventAction.key, + targetName, + targetType: "event-action", + }); + } + if (!actionDefinition.requiresMessage && messageKey) { + addIssue({ + code: "broken-reference", + field: "messageKey", + message: "Remove the Message reference from Wait For Continue before publishing.", + targetKey: eventAction.key, + targetName, + targetType: "event-action", + }); + } + }); + + return { + canPublish: issues.length === 0, + checkedAt: timestamp(), + issueCount: issues.length, + issues, + status: issues.length ? "Blocked" : "Ready", + valid: issues.length === 0, + }; + } } export function createMessagesPostgresService(options = {}) { @@ -983,6 +1468,14 @@ export async function handleMessagesApiContract({ const normalizedMethod = String(method || "GET").toUpperCase(); const resource = parts[0] || ""; const key = parts[1] || ""; + const action = parts[2] || ""; + + if (resource === "publish-validation" && normalizedMethod === "GET" && !key) { + return { + persistence: service.persistenceSummary(), + publishValidation: await service.validateMessagePublishConfiguration(), + }; + } if (resource === "messages") { if (normalizedMethod === "GET" && !key) { @@ -1003,7 +1496,13 @@ export async function handleMessagesApiContract({ persistence: service.persistenceSummary(), }; } - if (normalizedMethod === "POST" && key) { + if (normalizedMethod === "POST" && key && action === "delete") { + return { + message: await service.deleteMessage(key), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && key && !action) { return { message: await service.updateMessage(key, body, actorKey), persistence: service.persistenceSummary(), @@ -1086,6 +1585,33 @@ export async function handleMessagesApiContract({ } } + if (resource === "event-actions") { + if (normalizedMethod === "GET" && !key) { + return { + eventActions: await service.listMessageEventActions(), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "GET" && key) { + return { + eventAction: await service.getMessageEventAction(key), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && !key) { + return { + eventAction: await service.createMessageEventAction(body, actorKey), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && key) { + return { + eventAction: await service.updateMessageEventAction(key, body, actorKey), + persistence: service.persistenceSummary(), + }; + } + } + if (resource === "segments") { if (normalizedMethod === "GET" && !key) { return { @@ -1105,7 +1631,13 @@ export async function handleMessagesApiContract({ segment: await service.createMessageSegment(body, actorKey), }; } - if (normalizedMethod === "POST" && key) { + if (normalizedMethod === "POST" && key && action === "delete") { + return { + persistence: service.persistenceSummary(), + segment: await service.deleteMessageSegment(key), + }; + } + if (normalizedMethod === "POST" && key && !action) { return { persistence: service.persistenceSummary(), segment: await service.updateMessageSegment(key, body, actorKey), diff --git a/tests/dev-runtime/DbSeedIntegrity.test.mjs b/tests/dev-runtime/DbSeedIntegrity.test.mjs index 873dc1566..fe3f5e70f 100644 --- a/tests/dev-runtime/DbSeedIntegrity.test.mjs +++ b/tests/dev-runtime/DbSeedIntegrity.test.mjs @@ -95,14 +95,20 @@ test("Messages Local API seeds through the Postgres service and preserves respon assert.ok(urgent, "Messages emotion profiles should include Urgent"); const ttsProfiles = await apiJson(server.baseUrl, "/api/messages/tts-profiles"); - assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced TTS Profile"), true); - assert.equal(ttsProfiles.ttsProfiles[0].emotionSettings.some((setting) => setting.emotionLabel === "Urgent"), true); + const manProfile = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Man Profile 1"); + const womanProfile = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Woman Profile 2"); + assert.ok(manProfile, "Messages TTS profiles should include Man Profile 1 from Text To Speech"); + assert.ok(womanProfile, "Messages TTS profiles should include Woman Profile 2 from Text To Speech"); + assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced Profile"), true); + assert.deepEqual(manProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Neutral", "Calm", "Urgent"]); + assert.deepEqual(womanProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Whisper", "Robot"]); const created = await apiJson(server.baseUrl, "/api/messages/messages", { body: JSON.stringify({ emotionProfileKey: urgent.key, messageText: "Postgres-backed message text.", name: "Postgres Cutover Message", + voiceProfileKey: manProfile.key, }), method: "POST", }); @@ -110,6 +116,7 @@ test("Messages Local API seeds through the Postgres service and preserves respon assert.equal(created.message.categoryName, "Dialog"); assert.equal(created.message.emotionProfileName, "Urgent"); assert.equal(created.message.messageText, "Postgres-backed message text."); + assert.equal(created.message.voiceProfileName, "Man Profile 1"); const segment = await apiJson(server.baseUrl, "/api/messages/segments", { body: JSON.stringify({ @@ -117,11 +124,13 @@ test("Messages Local API seeds through the Postgres service and preserves respon emotionProfileKey: urgent.key, messageKey: created.message.key, segmentText: "Postgres-backed message part.", + voiceProfileKey: manProfile.key, }), method: "POST", }); assert.equal(segment.segment.messageName, "Postgres Cutover Message"); assert.equal(segment.segment.emotionProfileName, "Urgent"); + assert.equal(segment.segment.voiceProfileName, "Man Profile 1"); const list = await apiJson(server.baseUrl, "/api/messages/messages"); const listed = list.messages.find((message) => message.key === created.message.key); diff --git a/tests/dev-runtime/MessagesPublishValidation.test.mjs b/tests/dev-runtime/MessagesPublishValidation.test.mjs new file mode 100644 index 000000000..0fcec6b18 --- /dev/null +++ b/tests/dev-runtime/MessagesPublishValidation.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createMessagesPostgresService, + handleMessagesApiContract, +} from "../../src/dev-runtime/messages/messages-postgres-service.mjs"; +import { createMessagesPostgresClientStub } from "../helpers/messagesPostgresClientStub.mjs"; + +function createServiceHarness() { + const postgresClient = createMessagesPostgresClientStub(); + const service = createMessagesPostgresService({ postgresClient }); + return { postgresClient, service }; +} + +function datedRow(row) { + const now = "2026-06-22T00:00:00.000Z"; + return { + active: true, + createdAt: now, + createdBy: "test-author", + updatedAt: now, + updatedBy: "test-author", + ...row, + }; +} + +test("Messages publish validation passes publish-ready message configuration", async () => { + const { service } = createServiceHarness(); + + const emotion = (await service.listEmotionProfiles()).find((profile) => profile.name === "Calm"); + const voice = (await service.listTtsProfiles()).find((profile) => profile.name === "Man Profile 1"); + assert.ok(emotion); + assert.ok(voice); + + const message = await service.createMessage({ + emotionProfileKey: emotion.key, + messageText: "The bridge is open.", + name: "Bridge Open", + voiceProfileKey: voice.key, + }); + await service.createMessageSegment({ + displayOrder: 1, + emotionProfileKey: emotion.key, + messageKey: message.key, + segmentText: "The bridge is open.", + voiceProfileKey: voice.key, + }); + await service.createMessageEventAction({ + actionType: "speak-message", + messageKey: message.key, + name: "Speak bridge status", + }); + + const response = await handleMessagesApiContract({ + method: "GET", + parts: ["publish-validation"], + service, + }); + + assert.equal(response.publishValidation.valid, true); + assert.equal(response.publishValidation.canPublish, true); + assert.equal(response.publishValidation.issueCount, 0); + assert.deepEqual(response.publishValidation.issues, []); + service.close(); +}); + +test("Messages publish validation blocks invalid message and TTS references", async () => { + const { postgresClient, service } = createServiceHarness(); + await service.ensureReady(); + const categoryKey = await service.defaultMessageCategoryKey(); + + await postgresClient.requestTable("messages_tts_profiles", { + body: datedRow({ + description: "Configured for a future external provider.", + key: "voice-provider-openai", + language: "en-US", + name: "Future OpenAI Voice", + pitch: 1, + providerKey: "openai", + rate: 1, + voiceName: "Future voice", + volume: 1, + }), + method: "POST", + }); + await postgresClient.requestTable("messages_records", { + body: datedRow({ + categoryKey, + emotionProfileKey: "", + key: "message-invalid", + messageText: "", + name: "Invalid Message", + notes: "", + voiceProfileKey: "voice-provider-openai", + }), + method: "POST", + }); + await postgresClient.requestTable("messages_segments", { + body: datedRow({ + displayOrder: 1, + emotionProfileKey: "missing-emotion", + key: "segment-invalid", + messageKey: "message-missing", + segmentText: "", + voiceProfileKey: "", + }), + method: "POST", + }); + await postgresClient.requestTable("messages_event_actions", { + body: datedRow({ + actionType: "speak-message", + key: "event-action-invalid", + messageKey: "message-missing", + name: "Broken event action", + }), + method: "POST", + }); + + const validation = await service.validateMessagePublishConfiguration(); + const codes = validation.issues.map((issue) => issue.code); + + assert.equal(validation.valid, false); + assert.equal(validation.canPublish, false); + assert.ok(codes.includes("missing-message-text")); + assert.ok(codes.includes("missing-emotion-profile")); + assert.ok(codes.includes("missing-voice-profile")); + assert.ok(codes.includes("broken-reference")); + assert.ok(codes.includes("invalid-provider-assignment")); + assert.equal(validation.issues.every((issue) => issue.message && !/postgres|sql|stack|econn/i.test(issue.message)), true); + service.close(); +}); diff --git a/tests/helpers/messagesPostgresClientStub.mjs b/tests/helpers/messagesPostgresClientStub.mjs index 89eca61d7..3955f5f0f 100644 --- a/tests/helpers/messagesPostgresClientStub.mjs +++ b/tests/helpers/messagesPostgresClientStub.mjs @@ -77,6 +77,21 @@ export function createMessagesPostgresClientStub() { return clone(patched); } + if (normalizedMethod === "DELETE") { + if (!filter) { + throw new Error(`DELETE ${tableName} requires an equality filter.`); + } + const deleted = []; + for (let index = rows.length - 1; index >= 0; index -= 1) { + if (String(rows[index][filter.key]) !== filter.value) { + continue; + } + deleted.unshift(rows[index]); + rows.splice(index, 1); + } + return clone(deleted); + } + throw new Error(`Unsupported Messages Postgres test method: ${normalizedMethod}.`); }, }; diff --git a/tests/playwright/tools/EventsTool.spec.mjs b/tests/playwright/tools/EventsTool.spec.mjs new file mode 100644 index 000000000..d17be8dd4 --- /dev/null +++ b/tests/playwright/tools/EventsTool.spec.mjs @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; +import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; +import { createMessagesPostgresClientStub } from "../../helpers/messagesPostgresClientStub.mjs"; +import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; + +async function jsonRequest(url, options = {}) { + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + ...options, + }); + const payload = await response.json().catch(() => null); + return { payload, response }; +} + +test.beforeEach(async ({ page }) => { + await installPlaywrightStorageIsolation(page, { + lane: "events-tool", + surface: "Events Message Event Actions", + }); +}); + +test.afterEach(async ({ page }) => { + await clearPlaywrightStorage(page); +}); + +async function createMessage(server) { + const emotionResult = await jsonRequest(`${server.baseUrl}/api/messages/emotion-profiles`); + const voiceResult = await jsonRequest(`${server.baseUrl}/api/messages/tts-profiles`); + const urgent = emotionResult.payload.data.emotionProfiles.find((profile) => profile.name === "Urgent"); + const ttsProfile = voiceResult.payload.data.ttsProfiles.find((profile) => profile.name === "Man Profile 1"); + expect(urgent).toBeTruthy(); + expect(ttsProfile).toBeTruthy(); + const messageResult = await jsonRequest(`${server.baseUrl}/api/messages/messages`, { + body: JSON.stringify({ + emotionProfileKey: urgent.key, + messageText: "Open the ancient door.", + name: "Door Prompt", + voiceProfileKey: ttsProfile.key, + }), + method: "POST", + }); + expect(messageResult.response.ok).toBe(true); + expect(messageResult.payload.ok).toBe(true); + return messageResult.payload.data.message; +} + +async function openEventsPage(page) { + const server = await startRepoServer({ messagesPostgresClient: createMessagesPostgresClientStub() }); + const failures = { + consoleErrors: [], + failedRequests: [], + pageErrors: [], + server, + }; + page.on("pageerror", (error) => { + failures.pageErrors.push(error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + failures.consoleErrors.push(message.text()); + } + }); + page.on("response", (response) => { + if (response.status() >= 400) { + failures.failedRequests.push(`${response.status()} ${response.url()}`); + } + }); + await page.addInitScript(({ apiUrl }) => { + window.GameFoundryPublicConfig = { + apiUrl, + environmentLabel: "Development Environment", + siteUrl: window.location.origin, + }; + }, { + apiUrl: `${server.baseUrl}/api`, + }); + const message = await createMessage(server); + await page.goto(`${server.baseUrl}/tools/events/index.html`, { waitUntil: "networkidle" }); + return { ...failures, message }; +} + +test("Events supports Local API message event actions", async ({ page }) => { + const run = await openEventsPage(page); + try { + await expect(page.getByRole("heading", { level: 1, name: "Events" })).toBeVisible(); + await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); + await expect(page.getByLabel("Message Event Actions").getByRole("columnheader")).toHaveText(["Action", "Event Option", "Message", "Updated", "Actions"]); + await expect(page.getByRole("button", { name: "Add Action" })).toBeVisible(); + await expect(page.locator("[data-events-actions-table]")).toContainText("No event actions yet. Add an action when your event flow is ready."); + + await page.getByRole("button", { name: "Add Action" }).click(); + await expect(page.locator("[data-events-action-editor='__new__'] [data-event-action-type] option")).toHaveText([ + "Select action", + "Show Message", + "Speak Message", + "Wait For Continue", + ]); + await page.locator("[data-events-action-commit='__new__']").click(); + await expect(page.locator("[data-events-validation-errors]")).toContainText("Action is required."); + await expect(page.locator("[data-events-validation-errors]")).toContainText("Event option is required."); + + await page.locator("[data-event-action-name]").fill("Show door prompt"); + await page.locator("[data-event-action-type]").selectOption("show-message"); + await page.locator("[data-event-action-message]").selectOption(run.message.key); + await page.locator("[data-events-action-commit='__new__']").click(); + await expect(page.locator("[data-events-log]")).toHaveText("Saved event action Show door prompt."); + await expect(page.locator("[data-events-action-row]").filter({ hasText: "Show door prompt" })).toContainText("Door Prompt"); + + const listResult = await jsonRequest(`${run.server.baseUrl}/api/messages/event-actions`); + const saved = listResult.payload.data.eventActions.find((action) => action.name === "Show door prompt"); + expect(saved).toEqual(expect.objectContaining({ + actionLabel: "Show Message", + actionType: "show-message", + messageKey: run.message.key, + messageName: "Door Prompt", + })); + + await page.locator("[data-events-action-row]").filter({ hasText: "Show door prompt" }).getByRole("button", { name: "Edit" }).click(); + await page.locator("[data-event-action-type]").selectOption("speak-message"); + await page.locator("[data-events-action-commit]").click(); + await expect(page.locator("[data-events-action-row]").filter({ hasText: "Show door prompt" })).toContainText("Speak Message"); + + await page.getByRole("button", { name: "Add Action" }).click(); + await page.locator("[data-event-action-name]").fill("Wait for player"); + await page.locator("[data-event-action-type]").selectOption("wait-for-continue"); + await page.locator("[data-events-action-commit='__new__']").click(); + await expect(page.locator("[data-events-action-row]").filter({ hasText: "Wait for player" })).toContainText("No message required"); + + expect(run.failedRequests).toEqual([]); + expect(run.pageErrors).toEqual([]); + expect(run.consoleErrors).toEqual([]); + } finally { + await run.server.close(); + } +}); diff --git a/tests/playwright/tools/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs index dd2b371b8..c4322ef24 100644 --- a/tests/playwright/tools/MessagesTool.spec.mjs +++ b/tests/playwright/tools/MessagesTool.spec.mjs @@ -24,7 +24,7 @@ async function jsonRequest(url, options = {}) { test.beforeEach(async ({ page }) => { await installPlaywrightStorageIsolation(page, { lane: "messages-tool", - surface: "Message Studio parent/child table, Local API, and Theme V2 tool", + surface: "Message Studio table-first Messages and child sentence tool", }); }); @@ -64,51 +64,52 @@ async function openMessagesPage(page, options = {}) { failures.failedRequests.push(`FAILED ${request.url()}`); }); - await page.addInitScript(({ apiUrl, speechAvailable }) => { + await page.addInitScript(({ apiUrl }) => { window.GameFoundryPublicConfig = { apiUrl, environmentLabel: "Development Environment", siteUrl: window.location.origin, }; - window.__messagesSpeechCalls = []; - if (!speechAvailable) { - Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: undefined }); - Object.defineProperty(window, "speechSynthesis", { configurable: true, value: undefined }); - return; + }, { + apiUrl: options.apiUrl || `${server.baseUrl}/api`, + }); + await page.addInitScript(() => { + window.__spokenUtterances = []; + class FakeSpeechSynthesisUtterance { + constructor(text) { + this.lang = ""; + this.onend = null; + this.onerror = null; + this.pitch = 1; + this.rate = 1; + this.text = text; + this.voice = null; + this.volume = 1; + } } - Object.defineProperty(window, "SpeechSynthesisUtterance", { - configurable: true, - value: class SpeechSynthesisUtterance { - constructor(text = "") { - this.text = text; - } + window.SpeechSynthesisUtterance = FakeSpeechSynthesisUtterance; + window.speechSynthesis = { + cancel() { + window.__speechCanceled = true; }, - }); - Object.defineProperty(window, "speechSynthesis", { - configurable: true, - value: { - cancel() { - window.__messagesSpeechCalls.push({ type: "cancel" }); - }, - getVoices() { - return [{ lang: "en-US", name: "Test Voice", voiceURI: "test-voice-uri" }]; - }, - speak(utterance) { - window.__messagesSpeechCalls.push({ - lang: utterance.lang, - pitch: utterance.pitch, - rate: utterance.rate, - text: utterance.text, - type: "speak", - voiceName: utterance.voice?.name || "", - volume: utterance.volume, - }); - }, + getVoices() { + return [ + { lang: "en-US", name: "Browser guide updated", voiceURI: "Browser guide updated" }, + { lang: "en-US", name: "Browser default", voiceURI: "Browser default" }, + ]; }, - }); - }, { - apiUrl: `${server.baseUrl}/api`, - speechAvailable: options.speechAvailable !== false, + speak(utterance) { + window.__spokenUtterances.push({ + lang: utterance.lang, + pitch: utterance.pitch, + rate: utterance.rate, + text: utterance.text, + voiceName: utterance.voice?.name || "", + volume: utterance.volume, + }); + setTimeout(() => utterance.onend?.(), 0); + }, + }; }); await workspaceV2CoverageReporter.start(page); await page.goto(`${server.baseUrl}/tools/messages/index.html`, { waitUntil: "networkidle" }); @@ -123,213 +124,392 @@ async function closeMessagesRun(failures, page) { 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-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-row-editor='__new__'] [data-message-tts-profile]").selectOption({ label: values.ttsProfile }); await page.locator("[data-messages-commit='__new__']").click(); } -async function addPart(page, values) { - await page.getByRole("button", { name: "Add Part" }).click(); +async function addSentence(page, values) { + await page.getByRole("button", { name: "Add Sentence" }).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-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(); } -async function openMessageParts(page, messageName) { - await page.locator("[data-messages-row]").filter({ hasText: messageName }).locator("td").nth(1).click(); +async function ensureSentencesExpanded(page, messageName) { + const messageRow = page.locator("[data-messages-row]").filter({ hasText: messageName }); + if (await page.locator("[data-messages-parts-host]").count() === 0) { + await messageRow.getByRole("button", { name: "Sentences" }).click(); + } + await expect(page.locator("[data-messages-parts-host]")).toBeVisible(); + return messageRow; +} + +async function expectPlaybackDiagnostics(page, { + ageFilter = "Any", + gender = "Any", + language = "en-US", + profile, + voice, +}) { + const log = page.locator("[data-messages-log]"); + await expect(log).toContainText("Playing:"); + await expect(log).toContainText(`Profile: ${profile}`); + await expect(log).toContainText(`Gender: ${gender}`); + await expect(log).toContainText(`Voice: ${voice}`); + await expect(log).toContainText(`Language: ${language}`); + await expect(log).toContainText(`Age Filter: ${ageFilter}`); } -test("Message Studio renders Messages with child Message Parts and plays ordered parts", async ({ page }) => { +async function createdMessage(server, name) { + const listResult = await jsonRequest(`${server.baseUrl}/api/messages/messages`); + expect(listResult.response.ok).toBe(true); + expect(listResult.payload.ok).toBe(true); + return listResult.payload.data.messages.find((message) => message.name === name); +} + +async function emotionProfile(server, name) { + const profilesResult = await jsonRequest(`${server.baseUrl}/api/messages/emotion-profiles`); + expect(profilesResult.response.ok).toBe(true); + expect(profilesResult.payload.ok).toBe(true); + return profilesResult.payload.data.emotionProfiles.find((profile) => profile.name === name); +} + +async function emotionProfiles(server) { + const profilesResult = await jsonRequest(`${server.baseUrl}/api/messages/emotion-profiles`); + expect(profilesResult.response.ok).toBe(true); + expect(profilesResult.payload.ok).toBe(true); + return profilesResult.payload.data.emotionProfiles; +} + +async function voiceProfile(server, name) { + const profilesResult = await jsonRequest(`${server.baseUrl}/api/messages/tts-profiles`); + expect(profilesResult.response.ok).toBe(true); + expect(profilesResult.payload.ok).toBe(true); + return profilesResult.payload.data.ttsProfiles.find((profile) => profile.name === name); +} + +async function voiceProfiles(server) { + const profilesResult = await jsonRequest(`${server.baseUrl}/api/messages/tts-profiles`); + expect(profilesResult.response.ok).toBe(true); + expect(profilesResult.payload.ok).toBe(true); + return profilesResult.payload.data.ttsProfiles; +} + +async function createReferencedSentence(server, message, emotionName = "Urgent", voiceName = "Man Profile 1") { + const emotion = await emotionProfile(server, emotionName); + const voice = await voiceProfile(server, voiceName); + expect(emotion).toBeTruthy(); + expect(voice).toBeTruthy(); + const segmentResult = await jsonRequest(`${server.baseUrl}/api/messages/segments`, { + body: JSON.stringify({ + displayOrder: 1, + emotionProfileKey: emotion.key, + messageKey: message.key, + segmentText: "Referenced sentence.", + voiceProfileKey: voice.key, + }), + method: "POST", + }); + expect(segmentResult.response.ok).toBe(true); + expect(segmentResult.payload.ok).toBe(true); + return segmentResult.payload.data.segment; +} + +test("Message Studio uses the approved table-first Messages structure", async ({ page }) => { const failures = await openMessagesPage(page); try { await expect(page.getByRole("heading", { level: 1, name: "Message Studio" })).toBeVisible(); 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-tts-add-row]")).toHaveCount(0); - await expect(page.locator("[data-messages-emotions]")).toHaveCount(0); - await expect(page.locator("[data-messages-tts-profiles]")).toHaveCount(0); - await expect(page.locator("[data-messages-preview-tts-profile]")).toHaveCount(0); - await expect(page.locator("[data-messages-tts-service]")).toHaveCount(0); - await expect(page.locator("[data-messages-test-speech]")).toHaveCount(0); - await expect(page.getByText("Speech Test", { exact: true })).toHaveCount(0); - await expect(page.getByRole("columnheader", { name: "Message", exact: true })).toBeVisible(); - await expect(page.getByRole("columnheader", { exact: true, name: "TTS Profile" })).toBeVisible(); - await expect(page.locator("[data-messages-segment-count]")).toHaveText("0"); - await expect(page.locator("[data-messages-add-control-row]")).toBeVisible(); - await expect(page.getByRole("button", { name: "Stop Playback" })).toBeEnabled(); - await expect(page.locator("[data-messages-playback-status]")).toHaveText("Ready for Message Studio playback."); + await expect(page.getByRole("button", { name: "Add Message" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Run Validation" })).toBeVisible(); + await expect(page.locator("[data-messages-publish-status]")).toHaveText("Not checked"); + await expect(page.locator("[data-messages-publish-issues]")).toContainText("Run validation before publishing."); + await expect(page.locator("[data-messages-table]")).toContainText("No messages yet. Add your first message when you are ready."); + + const expectedHeaders = ["Message", "TTS Profile", "Updated", "Actions"]; + await expect(page.getByLabel("Messages").getByRole("columnheader")).toHaveText(expectedHeaders); + await expect(page.getByText("Reusable Assets", { exact: true })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Emotion Profiles" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Voice Profiles" })).toHaveCount(0); + await expect(page.getByText("TTS Profile / Emotion Settings", { exact: true })).toHaveCount(0); + await expect(page.getByText("Emotion Settings", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Emotion Profiles")).toHaveCount(0); + await expect(page.getByLabel("Voice Profiles")).toHaveCount(0); + await expect(page.getByText("Message Parts", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Add Emotion" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Add Voice" })).toHaveCount(0); + await expect(page.getByRole("columnheader", { name: "Type" })).toHaveCount(0); + await expect(page.getByRole("columnheader", { name: "Status" })).toHaveCount(0); + await expect(page.getByRole("columnheader", { name: "Parts" })).toHaveCount(0); + await expect(page.getByRole("columnheader", { name: "Emotion", exact: true })).toHaveCount(0); + await expect(page.getByRole("columnheader", { name: "Voice", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Stop Playback" })).toHaveCount(0); + await expect(page.locator("[data-message-default-tts-profile], [data-segment-tts-profile]")).toHaveCount(0); await page.getByRole("button", { name: "Add Message" }).click(); await expect(page.locator("[data-messages-row-editor='__new__']")).toBeVisible(); + await expect(page.locator("[data-messages-row-editor='__new__'] td")).toHaveCount(4); + await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]")).toBeVisible(); + await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile] option")).toHaveText([ + "Select TTS profile", + "Default Balanced Profile", + "Man Profile 1", + "Woman Profile 2", + ]); + await expect(page.locator("[data-messages-row-editor='__new__']").getByRole("button", { name: "Save" })).toBeVisible(); + await expect(page.locator("[data-messages-row-editor='__new__']").getByRole("button", { name: "Cancel" })).toBeVisible(); + 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 is required."); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Text is required."); + await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message is required."); + await expect(page.locator("[data-messages-validation-errors]")).toContainText("TTS Profile is required."); await page.locator("[data-messages-cancel='__new__']").click(); await addMessage(page, { - emotion: "Urgent", name: "Bat Encounter", - notes: "Opening combat line.", - text: "Bats drop from the rafters.", + ttsProfile: "Man Profile 1", }); - 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(/.+/); - await expect(page.locator("[data-message-default-tts-profile]").first()).toContainText("Default Balanced TTS Profile"); - + await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Bat Encounter."); const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }); - const messageNameCell = page.locator("[data-messages-name-cell]").filter({ hasText: "Bat Encounter" }); - await expect(page.locator("[data-messages-segment-host]")).toHaveCount(0); - await expect(messageNameCell).toHaveAttribute("aria-expanded", "false"); - await messageRow.locator("td").nth(3).click(); - await expect(page.locator("[data-messages-segment-host]")).toBeVisible(); - await expect(messageNameCell).toHaveAttribute("aria-expanded", "true"); - await messageRow.locator("td").nth(2).click(); - await expect(page.locator("[data-messages-segment-host]")).toHaveCount(0); - await messageNameCell.click(); - await expect(page.locator("[data-messages-segment-host]")).toBeVisible(); - await expect(page.getByRole("heading", { name: "Message Parts" })).toBeVisible(); - const partsTable = page.getByLabel("Message parts"); - await expect(partsTable.getByRole("columnheader", { name: "Order" })).toHaveCount(0); - await expect(partsTable.getByRole("columnheader", { name: "Part Text" })).toBeVisible(); - await expect(partsTable.getByRole("columnheader", { name: "Emotion" })).toBeVisible(); - await expect(partsTable.getByRole("columnheader", { exact: true, name: "TTS Profile" })).toBeVisible(); - await expect(partsTable.getByRole("columnheader", { name: "Status" })).toBeVisible(); - await expect(partsTable.getByRole("columnheader", { name: "Actions" })).toBeVisible(); - - await page.getByRole("button", { name: "Add Part" }).click(); - await expect(page.locator("[data-messages-segment-add-control-row]")).toHaveCount(0); - await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]")).toBeVisible(); - await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).toBeVisible(); - await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-tts-profile]")).toContainText("Default Balanced TTS Profile"); + await expect(messageRow).toContainText("Man Profile 1"); + await expect(messageRow.getByRole("button", { name: "Sentences" })).toBeVisible(); + await expect(messageRow.getByRole("button", { name: "Edit" })).toBeVisible(); + await expect(messageRow.getByRole("button", { name: "Archive" })).toBeVisible(); + await expect(messageRow.getByRole("button", { name: "Delete" })).toBeEnabled(); + await expect(messageRow.getByRole("button", { name: "Play" })).toBeVisible(); + + await ensureSentencesExpanded(page, "Bat Encounter"); + await expect(page.locator("[data-messages-parts-host]")).toBeVisible(); + const partsTable = page.getByLabel("Bat Encounter Sentences"); + await expect(partsTable.getByRole("columnheader")).toHaveText(["Order", "Text", "Emotion", "Actions"]); + await expect(partsTable).toContainText("No sentences yet. Add a sentence for this message when ready."); + + await page.getByRole("button", { name: "Add Sentence" }).click(); + await expect(page.locator("[data-messages-segment-editor='__new__'] td")).toHaveCount(4); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-voice]")).toHaveCount(0); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ + "Select emotion", + "Neutral", + "Calm", + "Urgent", + ]); await page.locator("[data-messages-segment-commit='__new__']").click(); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Part Text is required."); + await expect(page.locator("[data-messages-validation-errors]")).toContainText("Sentence text is required."); await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion is required."); - await expect(page.locator("[data-messages-validation-errors]")).not.toContainText("Display Order is required."); await page.locator("[data-messages-segment-cancel='__new__']").click(); - await addPart(page, { + await addSentence(page, { emotion: "Calm", order: 1, text: "Bats drop from the rafters.", }); - await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 1."); - await addPart(page, { + await expect(page.locator("[data-messages-log]")).toHaveText("Saved sentence 1."); + const sentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Bats drop from the rafters." }); + await expect(sentenceRow).toContainText("1"); + await expect(sentenceRow).toContainText("Calm"); + await expect(sentenceRow.getByRole("button", { name: "Edit" })).toBeVisible(); + await expect(sentenceRow.getByRole("button", { name: "Archive" })).toBeVisible(); + await expect(sentenceRow.getByRole("button", { name: "Delete" })).toBeVisible(); + await expect(sentenceRow.getByRole("button", { name: "Play" })).toBeVisible(); + await expect(page.locator("[data-messages-usage-count]")).toHaveText("1"); + await expect(page.locator("[data-messages-reference-list]")).toContainText("Sentence 1: Bats drop from the rafters."); + await expect(page.locator("[data-messages-segment-count]")).toHaveText("1"); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Delete" })).toBeDisabled(); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Archive" })).toBeEnabled(); + + await page.evaluate(() => { + window.__spokenUtterances = []; + }); + await sentenceRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); + const spokenSentence = await page.evaluate(() => window.__spokenUtterances); + expect(spokenSentence).toHaveLength(1); + expect(spokenSentence[0]).toEqual(expect.objectContaining({ + lang: "en-US", + pitch: 1, + rate: 1, + text: "Bats drop from the rafters.", + voiceName: "", + volume: 1, + })); + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + await expectPlaybackDiagnostics(page, { + gender: "Male", + profile: "Man Profile 1", + voice: "Default browser voice", + }); + + await addSentence(page, { emotion: "Urgent", order: 2, - text: "Keep your torch high.", + text: "Keep low.", }); - 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"); - const ttsProfilesResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/tts-profiles`); - expect(ttsProfilesResult.response.ok).toBe(true); - expect(ttsProfilesResult.payload.data.ttsProfiles[0].emotionSettings).toEqual(expect.arrayContaining([ - expect.objectContaining({ - emotion: "urgent", - emotionLabel: "Urgent", - pitch: 1.08, - rate: 1.15, - volume: 1, - }), - ])); - const urgentPartRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }); - await urgentPartRow.locator("[data-segment-tts-profile]").evaluate((select) => { - const option = document.createElement("option"); - option.value = "missing-profile"; - option.textContent = "Missing Profile"; - select.append(option); - select.value = option.value; - select.dispatchEvent(new Event("change", { bubbles: true })); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved sentence 2."); + const secondSentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Keep low." }); + await expect(secondSentenceRow).toContainText("2"); + await expect(secondSentenceRow.getByRole("button", { name: "Play" })).toBeVisible(); + await expect(page.locator("[data-messages-segment-count]")).toHaveText("2"); + await expect(page.locator("[data-messages-reference-list]")).toContainText("Sentence 2: Keep low."); + + await page.evaluate(() => { + window.__spokenUtterances = []; }); - await urgentPartRow.getByRole("button", { name: "Play Part" }).click(); - await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Select a TTS profile before playback."); - await expect(page.locator("[data-messages-playback-status]")).toHaveText("Select a TTS profile before playback."); - await page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }).locator("[data-segment-tts-profile]").selectOption({ label: "Default Balanced TTS Profile" }); - - 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.slice(-2)).toEqual([ - expect.objectContaining({ - pitch: 1, - rate: 1, - text: "Bats drop from the rafters.", - volume: 1, - }), - expect.objectContaining({ - pitch: 1.08, - rate: 1.15, - text: "Keep your torch high.", - volume: 1, - }), - ]); - expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ - lang: "en-US", - type: "speak", - voiceName: "Test Voice", - })); - await page.getByRole("button", { name: "Stop Playback" }).click(); - await expect(page.locator("[data-messages-log]")).toHaveText("Message Studio playback stopped. Cleared 2 queued items."); - speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); - expect(speechCalls.at(-1)).toEqual({ type: "cancel" }); - - 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 Default Balanced TTS Profile."); - speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); - expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ + await secondSentenceRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); + const spokenSecondSentence = await page.evaluate(() => window.__spokenUtterances); + expect(spokenSecondSentence).toHaveLength(1); + expect(spokenSecondSentence[0]).toEqual(expect.objectContaining({ pitch: 1.08, rate: 1.15, - text: "Keep your torch high.", - type: "speak", - voiceName: "Test Voice", + text: "Keep low.", + voiceName: "", volume: 1, })); + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + await expectPlaybackDiagnostics(page, { + gender: "Male", + profile: "Man Profile 1", + voice: "Default browser voice", + }); - 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.getByRole("button", { name: "Run Validation" }).click(); + await expect(page.locator("[data-messages-publish-status]")).toHaveText("Ready"); + await expect(page.locator("[data-messages-publish-issues]")).toContainText("No message publish issues."); + await expect(page.locator("[data-messages-log]")).toHaveText("Publish validation passed."); + const publishValidationResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/publish-validation`); + expect(publishValidationResult.response.ok).toBe(true); + expect(publishValidationResult.payload.ok).toBe(true); + expect(publishValidationResult.payload.data.publishValidation).toEqual(expect.objectContaining({ + canPublish: true, + issueCount: 0, + status: "Ready", + valid: true, + })); - 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.evaluate(() => { + window.__spokenUtterances = []; + }); + await messageRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length >= 2); + const spokenMessage = await page.evaluate(() => window.__spokenUtterances); + expect(spokenMessage.map((utterance) => utterance.text)).toEqual(["Bats drop from the rafters.", "Keep low."]); + expect(spokenMessage[0]).toEqual(expect.objectContaining({ + pitch: 1, + rate: 1, + voiceName: "", + volume: 1, + })); + expect(spokenMessage[1]).toEqual(expect.objectContaining({ + pitch: 1.08, + rate: 1.15, + voiceName: "", + volume: 1, + })); + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + await expectPlaybackDiagnostics(page, { + gender: "Male", + profile: "Man Profile 1", + voice: "Default browser voice", + }); + await page.getByRole("button", { name: "Stop Speech" }).click(); + await expect(page.locator("[data-messages-speech-status]")).toHaveText("Speech playback stopped."); + + await messageRow.getByRole("button", { name: "Archive" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Archived message Bat Encounter."); + const archivedMessageRow = page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter (Archived)" }); + await expect(archivedMessageRow.getByRole("button", { name: "Play" })).toBeVisible(); + await expect(archivedMessageRow.getByRole("button", { name: "Restore" })).toBeVisible(); + await expect(archivedMessageRow.getByRole("button", { name: "Delete" })).toBeDisabled(); + await archivedMessageRow.getByRole("button", { name: "Restore" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Restored message Bat Encounter."); + await expect(messageRow.getByRole("button", { name: "Play" })).toBeVisible(); + + await sentenceRow.getByRole("button", { name: "Archive" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Archived sentence 1."); + const archivedSentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Bats drop from the rafters." }); + await expect(archivedSentenceRow).toContainText("1 (Archived)"); + await expect(archivedSentenceRow.getByRole("button", { name: "Play" })).toBeVisible(); + await expect(archivedSentenceRow.getByRole("button", { name: "Restore" })).toBeVisible(); + await expect(messageRow.getByRole("button", { name: "Play" })).toBeVisible(); + + await archivedSentenceRow.getByRole("button", { name: "Restore" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Restored sentence 1."); + + await sentenceRow.getByRole("button", { name: "Edit" }).click(); + await expect(page.locator("[data-messages-segment-editor]").getByRole("button", { name: "Save" })).toBeVisible(); + await expect(page.locator("[data-messages-segment-editor]").getByRole("button", { name: "Cancel" })).toBeVisible(); + await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("Temporary sentence edit"); + await page.locator("[data-messages-segment-editor] [data-messages-segment-cancel]").click(); + await expect(sentenceRow).toContainText("Bats drop from the rafters."); + await expect(sentenceRow).not.toContainText("Temporary sentence edit"); + + await sentenceRow.getByRole("button", { name: "Edit" }).click(); + await page.locator("[data-messages-segment-editor] [data-segment-order]").fill("2"); + await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("Bats drop from the rafters. Shields up."); await page.locator("[data-messages-segment-editor] [data-messages-segment-commit]").click(); - await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2."); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved sentence 2."); + const editedSentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Bats drop from the rafters. Shields up." }); + await expect(editedSentenceRow).toContainText("2"); + + await editedSentenceRow.getByRole("button", { name: "Delete" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Deleted sentence 2."); + await expect(page.locator("[data-messages-segment-row]")).toHaveCount(1); + await expect(page.locator("[data-messages-usage-count]")).toHaveText("1"); + await expect(secondSentenceRow.getByRole("button", { name: "Play" })).toBeVisible(); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play" })).toBeVisible(); + await secondSentenceRow.getByRole("button", { name: "Delete" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Deleted sentence 2."); + await expect(page.locator("[data-messages-segment-row]")).toHaveCount(0); + await expect(page.locator("[data-messages-usage-count]")).toHaveText("0"); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Delete" })).toBeEnabled(); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play" })).toBeVisible(); + await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play" }).click(); + await expect(page.locator("[data-messages-validation-errors]")).toContainText("Add at least one sentence before playing this message."); + await expect(page.locator("[data-messages-speech-status]")).toHaveText("No sentences available."); + + await messageRow.getByRole("button", { name: "Edit" }).click(); + await expect(page.locator("[data-messages-row-editor]").getByRole("button", { name: "Save" })).toBeVisible(); + await expect(page.locator("[data-messages-row-editor]").getByRole("button", { name: "Cancel" })).toBeVisible(); + await page.locator("[data-messages-row-editor] [data-message-name]").fill("Temporary Message Edit"); + await page.locator("[data-messages-row-editor] [data-messages-cancel]").click(); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toBeVisible(); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Temporary Message Edit" })).toHaveCount(0); + + await messageRow.getByRole("button", { name: "Edit" }).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("Saved message Bat Encounter Updated."); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter Updated" })).toContainText("Man Profile 1"); - 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({ + const updatedMessage = await createdMessage(failures.server, "Bat Encounter Updated"); + expect(updatedMessage).toEqual(expect.objectContaining({ active: true, categoryName: "Dialog", - emotionProfileName: "Urgent", - messageText: "Bats drop from the rafters.", - notes: "Opening combat line.", + voiceProfileName: "Man Profile 1", + })); + expect(updatedMessage.key).toMatch(ULID_PATTERN); + expect(updatedMessage).not.toHaveProperty("rate"); + expect(updatedMessage).not.toHaveProperty("pitch"); + expect(updatedMessage).not.toHaveProperty("volume"); + + const manProfile = (await voiceProfiles(failures.server)).find((profile) => profile.name === "Man Profile 1"); + expect(manProfile).toEqual(expect.objectContaining({ + gender: "male", + language: "en-US", + providerKey: "browser-speech", + voiceName: "Default browser voice", })); - expect(createdMessage.key).toMatch(ULID_PATTERN); - - const segmentsResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/segments`); - expect(segmentsResult.response.ok).toBe(true); - const createdSegments = segmentsResult.payload.data.segments.filter((segment) => segment.messageKey === createdMessage.key); - 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-row]").filter({ hasText: "Bat Encounter Updated" }).getByRole("button", { name: "Delete" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Deleted message Bat Encounter Updated."); + await expect(page.locator("[data-messages-row]")).toHaveCount(0); + await expect(page.locator("[data-messages-table]")).toContainText("No messages yet. Add your first message when you are ready."); expect(failures.failedRequests).toEqual([]); expect(failures.pageErrors).toEqual([]); @@ -339,28 +519,89 @@ test("Message Studio renders Messages with child Message Parts and plays ordered } }); -test("Message Studio shows actionable playback error when audio engine is unavailable", async ({ page }) => { - const failures = await openMessagesPage(page, { speechAvailable: false }); +test("Message Studio loads Text To Speech profiles and filters sentence emotions by selected profile", async ({ page }) => { + const failures = await openMessagesPage(page); try { - await addMessage(page, { - emotion: "Urgent", - name: "Bat Encounter", - text: "Bats drop from the rafters.", + await page.getByRole("button", { name: "Add Message" }).click(); + const profileOptions = page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile] option"); + await expect(profileOptions).toHaveText([ + "Select TTS profile", + "Default Balanced Profile", + "Man Profile 1", + "Woman Profile 2", + ]); + await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill("Profile Filter Test"); + await page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]").selectOption({ label: "Man Profile 1" }); + await page.locator("[data-messages-commit='__new__']").click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Profile Filter Test."); + + await ensureSentencesExpanded(page, "Profile Filter Test"); + await page.getByRole("button", { name: "Add Sentence" }).click(); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ + "Select emotion", + "Neutral", + "Calm", + "Urgent", + ]); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Whisper"); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Robot"); + await page.locator("[data-messages-segment-cancel='__new__']").click(); + + const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Profile Filter Test" }); + await messageRow.getByRole("button", { name: "Edit" }).click(); + await page.locator("[data-messages-row-editor] [data-message-tts-profile]").selectOption({ label: "Woman Profile 2" }); + await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Profile Filter Test."); + + await ensureSentencesExpanded(page, "Profile Filter Test"); + await page.getByRole("button", { name: "Add Sentence" }).click(); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ + "Select emotion", + "Whisper", + "Robot", + ]); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Calm"); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Urgent"); + await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill("1"); + await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill("Robot voice check."); + await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: "Robot" }); + await page.locator("[data-messages-segment-commit='__new__']").click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved sentence 1."); + + const sentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Robot voice check." }); + await page.evaluate(() => { + window.__spokenUtterances = []; }); - await openMessageParts(page, "Bat Encounter"); - await addPart(page, { - emotion: "Urgent", - order: 1, - text: "Bats drop from the rafters.", + await sentenceRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); + const spokenSentence = await page.evaluate(() => window.__spokenUtterances); + expect(spokenSentence).toEqual([expect.objectContaining({ + pitch: 0.82, + rate: 0.92, + text: "Robot voice check.", + volume: 0.9, + })]); + await expectPlaybackDiagnostics(page, { + gender: "Female", + profile: "Woman Profile 2", + voice: "Default browser voice", }); + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); - 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-playback-status]")).toHaveText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); - expect(await page.evaluate(() => window.__messagesSpeechCalls)).toEqual([]); + await page.evaluate(() => { + window.__spokenUtterances = []; + }); + await messageRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); + const spokenMessage = await page.evaluate(() => window.__spokenUtterances); + expect(spokenMessage.map((utterance) => utterance.text)).toEqual(["Robot voice check."]); + await expectPlaybackDiagnostics(page, { + gender: "Female", + profile: "Woman Profile 2", + voice: "Default browser voice", + }); expect(failures.failedRequests).toEqual([]); expect(failures.pageErrors).toEqual([]); @@ -370,58 +611,62 @@ test("Message Studio shows actionable playback error when audio engine is unavai } }); -test("Message Studio shows actionable playback error when selected TTS profile lacks the selected emotion", async ({ page }) => { - await page.route("**/api/messages/tts-profiles", async (route) => { - if (route.request().method() !== "GET") { - await route.continue(); - return; - } - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - data: { - persistence: { owner: "messages" }, - ttsProfiles: [{ - active: true, - emotionSettings: [{ active: true, emotion: "calm", emotionLabel: "Calm", pitch: 1, rate: 1, ssmlLikePreset: "normal", volume: 1 }], - key: "calm-only-profile", - language: "en-US", - name: "Calm Only Profile", - providerKey: "browser-speech", - voiceName: "", - }], - }, - ok: true, - }), - }); - }); +test("Message Studio disables Delete when a message is referenced", async ({ page }) => { const failures = await openMessagesPage(page); try { await addMessage(page, { - emotion: "Urgent", - name: "Urgent Encounter", - text: "Danger is close.", + name: "Referenced Encounter", + ttsProfile: "Man Profile 1", }); - await openMessageParts(page, "Urgent Encounter"); - await addPart(page, { - emotion: "Urgent", - order: 1, - text: "Danger is close.", + const message = await createdMessage(failures.server, "Referenced Encounter"); + const segment = await createReferencedSentence(failures.server, message); + expect(segment.key).toMatch(ULID_PATTERN); + + await page.reload({ waitUntil: "networkidle" }); + const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Referenced Encounter" }); + await messageRow.getByText("Referenced Encounter").click(); + const deleteButton = messageRow.getByRole("button", { name: "Delete" }); + await expect(deleteButton).toBeDisabled(); + await expect(deleteButton).toHaveAttribute("title", "Delete disabled: this message has sentences."); + await expect(messageRow.getByRole("button", { name: "Archive" })).toBeEnabled(); + await expect(page.locator("[data-messages-referenced-count]")).toHaveText("1"); + await expect(page.locator("[data-messages-usage-count]")).toHaveText("1"); + await expect(page.locator("[data-messages-reference-list]")).toContainText("Sentence 1: Referenced sentence."); + + await messageRow.getByRole("button", { name: "Archive" }).click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Archived message Referenced Encounter."); + await expect(page.locator("[data-messages-row]").filter({ hasText: "Referenced Encounter (Archived)" })).toBeVisible(); + const archived = await createdMessage(failures.server, "Referenced Encounter"); + expect(archived.active).toBe(false); + + const deleteResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages/${encodeURIComponent(message.key)}/delete`, { + method: "POST", }); + expect(deleteResult.response.status).toBe(409); + expect(deleteResult.payload.ok).toBe(false); - await page.locator("[data-messages-segment-row]").filter({ hasText: "Danger is close." }).getByRole("button", { name: "Play Part" }).click(); - const expectedError = 'Selected TTS Profile "Calm Only Profile" does not include an Emotion Setting for "Urgent".'; - await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); - await expect(page.locator("[data-messages-validation-errors]")).toContainText(expectedError); - await expect(page.locator("[data-messages-playback-status]")).toHaveText(expectedError); - expect(await page.evaluate(() => window.__messagesSpeechCalls)).toEqual([]); + expect(failures.failedRequests).toHaveLength(1); + expect(failures.failedRequests[0]).toContain("409"); + expect(failures.pageErrors).toEqual([]); + expect(failures.consoleErrors).toEqual([]); + } finally { + await closeMessagesRun(failures, page); + } +}); - expect(failures.failedRequests).toEqual([]); +test("Message Studio shows Creator-safe load failures", async ({ page }) => { + const failures = await openMessagesPage(page, { apiUrl: "http://127.0.0.1:9/api" }); + + try { + await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); + await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Studio could not load messages. Start the Local API and reload this tool."); + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText("ECONNREFUSED"); + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText("XMLHttpRequest"); + await expect(page.locator("[data-messages-log]")).toHaveText("Message Studio could not load messages. Start the Local API and reload this tool."); expect(failures.pageErrors).toEqual([]); expect(failures.consoleErrors).toEqual([]); } finally { - await page.unroute("**/api/messages/tts-profiles"); await closeMessagesRun(failures, page); } }); diff --git a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs index bf68c80f4..4b5269791 100644 --- a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs +++ b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs @@ -98,6 +98,14 @@ async function closeTextToSpeechRun(failures, page) { await failures.server.close(); } +async function setRangeValue(locator, value) { + await locator.evaluate((input, nextValue) => { + input.value = nextValue; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, String(value)); +} + test("Text To Speech page loads and speaks through browser speech synthesis", async ({ page }) => { const failures = await openTextToSpeechPage(page); try { @@ -108,6 +116,10 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as await expect(page.locator("[data-tts-output-summary]")).toHaveCount(0); await expect(page.locator("[data-tts-queue-list]")).toHaveCount(0); await expect(page.locator("[data-tts-item-name]")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Speech Composition" })).toBeVisible(); + await expect(page.getByLabel("Text To Speak")).toBeVisible(); + await expect(page.getByText("TTS Profile / Emotion Settings", { exact: true })).toHaveCount(0); + await expect(page.getByText("Emotion Settings", { exact: true })).toHaveCount(0); await expect(page.getByText("Named Sentence", { exact: true })).toHaveCount(0); await expect(page.getByText("Output Summary", { exact: true })).toHaveCount(0); await expect(page.getByText("Voice Filters", { exact: true })).toHaveCount(0); @@ -120,8 +132,16 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as await expect(page.locator("[data-tts-profile-table]")).toContainText("Default Balanced Profile"); await expect(page.locator("[data-tts-profile-table]")).toContainText("Man Profile 1"); await expect(page.locator("[data-tts-profile-table]")).toContainText("Woman Profile 2"); - await expect(page.getByRole("columnheader", { exact: true, name: "Profile" })).toBeVisible(); - await expect(page.getByRole("columnheader", { name: "Age Filter" })).toBeVisible(); + await expect(page.getByLabel("TTS Profiles").getByRole("columnheader")).toHaveText([ + "Profile", + "Gender", + "Voice", + "Language", + "Age Filter", + "Emotion Count", + "Status", + "Actions", + ]); const defaultProfileRow = page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }); await expect(defaultProfileRow.getByRole("button", { name: "Delete" })).toBeDisabled(); await expect(page.locator("[data-tts-emotion-host]")).toHaveCount(0); @@ -130,11 +150,18 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as await expect(defaultProfileRow.locator("[data-tts-profile-name-cell]")).toHaveAttribute("aria-expanded", "false"); await defaultProfileRow.locator("[data-tts-profile-name-cell]").click(); await expect(defaultProfileRow.locator("[data-tts-profile-name-cell]")).toHaveAttribute("aria-expanded", "true"); - await expect(page.getByRole("heading", { name: "Emotion Settings" })).toBeVisible(); - await expect(page.getByRole("columnheader", { name: "Emotion", exact: true })).toBeVisible(); - await expect(page.getByRole("columnheader", { name: "Delivery Preset" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Emotion Settings" })).toHaveCount(0); + await expect(page.getByLabel("TTS Profile Emotions").getByRole("columnheader")).toHaveText([ + "Emotion", + "Pitch", + "Rate", + "Volume", + "Actions", + ]); + await expect(page.getByRole("columnheader", { name: "Delivery Preset" })).toHaveCount(0); await expect(page.locator("[data-tts-emotion-row]")).toHaveCount(4); await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Delete" })).toBeDisabled(); + await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Play" })).toBeVisible(); await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Happy" })).toBeVisible(); await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Angry" })).toBeVisible(); await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Scared" })).toBeVisible(); @@ -157,29 +184,40 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as await expect(page.locator("[data-tts-profile-editor='__new__']")).toBeVisible(); await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Creature Profile"); await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("neutral"); + const newProfileVoiceSelect = page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-voice]"); + await expect(newProfileVoiceSelect).toContainText("Arcade Voice (en-US)"); + await expect(newProfileVoiceSelect).toContainText("Narrator Voice (en-GB)"); + await newProfileVoiceSelect.selectOption("narrator-voice-uri"); await page.locator("[data-tts-commit-profile='__new__']").click(); await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile."); await expect(page.locator("[data-tts-profile-table]")).toContainText("Creature Profile"); + await expect(page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile" })).toContainText("Narrator Voice"); await page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile" }).getByRole("button", { name: "Edit Profile" }).click(); + await expect(page.locator("[data-tts-profile-editor] [data-tts-profile-voice]")).toHaveValue("narrator-voice-uri"); await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Creature Profile Updated"); await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile Updated."); + await expect(page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile Updated" })).toContainText("Narrator Voice"); await expect(page.locator("[data-tts-emotion-add-control-row]").getByRole("button", { name: "Add Emotion" })).toBeVisible(); await page.locator("[data-tts-emotion-add-control-row]").getByRole("button", { name: "Add Emotion" }).click(); await expect(page.locator("[data-tts-emotion-add-control-row]")).toHaveCount(0); await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); - await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]").fill("1.2"); - await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]").fill("1.1"); - await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]").fill("0.8"); - await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-ssml-preset]").selectOption("whisper-ish"); + await expect(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]")).toHaveAttribute("type", "range"); + await expect(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]")).toHaveAttribute("type", "range"); + await expect(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]")).toHaveAttribute("type", "range"); + await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]"), "1.2"); + await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]"), "1.1"); + await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]"), "0.8"); await page.locator("[data-tts-commit-emotion='__new__']").click(); - await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion: Urgent."); await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" })).toContainText("1.2"); await page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" }).getByRole("button", { name: "Edit Emotion" }).click(); - await page.locator("[data-tts-emotion-editor] [data-tts-emotion-volume]").fill("0.7"); + await setRangeValue(page.locator("[data-tts-emotion-editor] [data-tts-emotion-volume]"), "0.7"); await page.locator("[data-tts-emotion-editor] [data-tts-commit-emotion]").click(); - await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); - await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" })).toContainText("Whisper-ish"); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion: Urgent."); + const urgentEmotionRow = page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" }); + await expect(urgentEmotionRow).toContainText("0.7"); + await expect(urgentEmotionRow.getByRole("button", { name: "Play" })).toBeVisible(); await expect(page.locator("[data-tts-gender-select]")).toHaveCount(0); await expect(page.locator("[data-tts-language-select]")).toHaveCount(0); @@ -199,17 +237,30 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as await page.locator("[data-tts-text-input]").fill("Launch the next wave."); await expect(page.locator("[data-tts-text-count]")).toHaveText("21"); + await urgentEmotionRow.getByRole("button", { name: "Play" }).click(); + await expect(page.locator("[data-tts-status]")).toContainText("Speech queued"); + let calls = await page.evaluate(() => window.__textToSpeechCalls); + expect(calls.at(-1)).toEqual(expect.objectContaining({ + lang: "en-GB", + pitch: 1.2, + rate: 1.1, + text: "Launch the next wave.", + type: "speak", + voiceName: "Narrator Voice", + volume: 0.7, + })); + await expect(page.locator("[data-tts-speak]")).toBeEnabled(); await page.locator("[data-tts-speak]").click(); await expect(page.locator("[data-tts-status]")).toContainText("Speech queued"); - let calls = await page.evaluate(() => window.__textToSpeechCalls); + calls = await page.evaluate(() => window.__textToSpeechCalls); expect(calls.at(-1)).toEqual(expect.objectContaining({ - lang: "en-US", + lang: "en-GB", pitch: 1.2, rate: 1.1, text: "Launch the next wave.", type: "speak", - voiceName: "Arcade Voice", + voiceName: "Narrator Voice", volume: 0.7, })); @@ -287,6 +338,12 @@ test("Text To Speech shows actionable error when browser speech synthesis is una await expect(page.locator("[data-tts-voice-select]")).toHaveCount(0); await expect(page.locator("[data-tts-speak]")).toBeDisabled(); await expect(page.locator("[data-tts-stop]")).toBeDisabled(); + const defaultProfileRow = page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }); + await defaultProfileRow.locator("[data-tts-profile-name-cell]").click(); + await page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Play" }).click(); + await expect(page.locator("[data-tts-status]")).toHaveText("SpeechSynthesis is unavailable in this browser. Use a browser with Web Speech API support."); + await expect(page.locator("[data-tts-status]")).not.toContainText("TypeError"); + await expect(page.locator("[data-tts-status]")).not.toContainText("undefined"); expect(failures.failedRequests).toEqual([]); expect(failures.pageErrors).toEqual([]); diff --git a/tests/tools/MessagesPlaybackSource.test.mjs b/tests/tools/MessagesPlaybackSource.test.mjs new file mode 100644 index 000000000..b0389d19a --- /dev/null +++ b/tests/tools/MessagesPlaybackSource.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("Messages playback runtime does not include preview voice validation text", async () => { + const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); + + assert.equal(source.includes("before preview"), false); + assert.equal(source.includes("available browser voice before preview"), false); + assert.equal(source.includes("Select an available browser voice before preview"), false); +}); + +test("Messages sentence emotion picker does not fall back to unrelated global emotions", async () => { + const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); + + assert.equal(source.includes("selectOptionsWithCurrent"), false); + assert.equal(source.includes("return options.length ? options :"), false); +}); + +test("Messages wires profile dropdowns through the Text To Speech profile contract", async () => { + const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); + + assert.equal(source.includes("../text-to-speech/text2speech.js"), true); + assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), true); + assert.equal(source.includes("createMessageStudioTtsProfileOptions"), true); + assert.equal(source.includes("state.voiceProfiles = voicePayload.ttsProfiles || []"), false); + assert.equal(source.includes("messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || [])"), true); +}); diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs index 719243bff..eea35e2cb 100644 --- a/tests/tools/Text2SpeechShell.test.mjs +++ b/tests/tools/Text2SpeechShell.test.mjs @@ -7,6 +7,7 @@ import { TTS_PROVIDER_ADAPTER_PLAN, createDefaultTextToSpeechProfiles, createEmotionProfile, + createMessageStudioDefaultTtsProfiles, createMessageStudioTtsProfileOptions, createSpeechPreviewRequest, createTextToSpeechProfile, @@ -25,9 +26,9 @@ test("Text2Speech message model separates Design and Audio ownership", () => { assert.equal(message.audioOwner, "Audio"); assert.equal(message.generatedAudio, null); assert.deepEqual(message.metadata.tags, ["intro"]); - assert.equal(emotion.owner, "Design"); + assert.equal(emotion.owner, "Audio"); assert.equal(emotion.intensity, 1); - assert.equal(voice.owner, "Design"); + assert.equal(voice.owner, "Audio"); assert.equal(voice.generatedAudioOwner, "Audio"); assert.ok(TTS_MESSAGE_STATUSES.includes("blocked")); }); @@ -68,6 +69,7 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; const defaults = createDefaultTextToSpeechProfiles(voiceOptions); + const messageStudioDefaults = createMessageStudioDefaultTtsProfiles(voiceOptions); const custom = createTextToSpeechProfile({ emotions: [ createTextToSpeechProfileEmotion({ @@ -91,21 +93,28 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt assert.deepEqual(defaults[0].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); assert.deepEqual(defaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); assert.deepEqual(defaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); + assert.deepEqual(messageStudioDefaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Calm", "Urgent"]); + assert.deepEqual(messageStudioDefaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Whisper", "Robot"]); assert.equal(defaults[0].emotions.find((emotion) => emotion.emotion === "neutral").messagePartsUsageCount, 1); assert.deepEqual(options, [{ active: true, + age: "any", + ageFilter: "any", emotionSettings: [{ emotion: "urgent", emotionLabel: "Urgent", + key: "urgent", pitch: 1.2, rate: 1.1, ssmlLikePreset: "whisper-ish", volume: 0.8, }], + gender: "neutral", key: "custom-profile", language: "en-US", name: "Custom Profile", providerKey: "browser-speech", + voice: "test-voice", voiceName: "Test Voice", }]); }); diff --git a/toolbox/events/events.js b/toolbox/events/events.js new file mode 100644 index 000000000..80df22928 --- /dev/null +++ b/toolbox/events/events.js @@ -0,0 +1,349 @@ +import { + requireServerApiData, + safeRequestServerApi, +} from "../../src/api/server-api-client.js"; + +const NEW_ROW_KEY = "__new__"; +const TABLE_COLSPAN = 5; +const EVENT_ACTION_OPTIONS = Object.freeze([ + Object.freeze({ key: "show-message", name: "Show Message", requiresMessage: true }), + Object.freeze({ key: "speak-message", name: "Speak Message", requiresMessage: true }), + Object.freeze({ key: "wait-for-continue", name: "Wait For Continue", requiresMessage: false }), +]); + +const elements = { + addAction: document.querySelector("[data-events-add-action]"), + log: document.querySelector("[data-events-log]"), + persistenceSource: document.querySelector("[data-events-persistence-source]"), + persistenceStorage: document.querySelector("[data-events-persistence-storage]"), + table: document.querySelector("[data-events-actions-table]"), + validationCard: document.querySelector("[data-events-validation-card]"), + validationErrors: document.querySelector("[data-events-validation-errors]"), +}; + +const state = { + editingKey: "", + eventActions: [], + messages: [], +}; + +function readData(path, context) { + return requireServerApiData(safeRequestServerApi(path), context); +} + +function writeData(path, body, context) { + return requireServerApiData( + safeRequestServerApi(path, { + body, + method: "POST", + }), + context, + ); +} + +function setText(element, value) { + if (element) { + element.textContent = value; + } +} + +function createCell(text) { + const cell = document.createElement("td"); + cell.textContent = text; + return cell; +} + +function createRowHeader(text) { + const cell = document.createElement("th"); + cell.scope = "row"; + cell.textContent = text; + return cell; +} + +function createInput(value, dataName, ariaLabel) { + const input = document.createElement("input"); + input.dataset[dataName] = ""; + input.type = "text"; + input.value = value || ""; + input.setAttribute("aria-label", ariaLabel); + return input; +} + +function createSelect(value, dataName, options, placeholder, ariaLabel) { + const select = document.createElement("select"); + select.dataset[dataName] = ""; + select.setAttribute("aria-label", ariaLabel); + const placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.textContent = placeholder; + select.append(placeholderOption); + options.forEach((optionValue) => { + const option = document.createElement("option"); + option.value = optionValue.key; + option.textContent = optionValue.name; + select.append(option); + }); + select.value = options.some((optionValue) => optionValue.key === value) ? value : ""; + return select; +} + +function createButton(label, dataName, value, options = {}) { + const button = document.createElement("button"); + button.className = options.primary ? "btn btn--compact primary" : "btn btn--compact"; + button.type = "button"; + button.dataset[dataName] = value; + button.textContent = label; + return button; +} + +function createActionGroup(...buttons) { + const group = document.createElement("div"); + group.className = "action-group action-group--tight"; + buttons.forEach((button) => group.append(button)); + return group; +} + +function tableMessage(text) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = TABLE_COLSPAN; + cell.textContent = text; + row.append(cell); + return row; +} + +function formatUpdated(value) { + const date = new Date(value || ""); + if (Number.isNaN(date.getTime())) { + return "Unknown"; + } + return date.toLocaleString(undefined, { + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + month: "short", + year: "numeric", + }); +} + +function actionTypeDefinition(actionType) { + return EVENT_ACTION_OPTIONS.find((option) => option.key === actionType) || null; +} + +function activeMessages() { + return state.messages.filter((message) => message.active !== false); +} + +function clearValidation() { + elements.validationErrors?.replaceChildren(); + if (elements.validationCard) { + elements.validationCard.hidden = true; + } +} + +function showValidation(errors) { + if (!elements.validationCard || !elements.validationErrors) { + return; + } + elements.validationErrors.replaceChildren(); + errors.forEach((error) => { + const item = document.createElement("li"); + item.textContent = error; + elements.validationErrors.append(item); + }); + elements.validationCard.hidden = errors.length === 0; +} + +function showCreatorSafeFailure(message) { + const safeMessage = message || "Event action could not be saved. Check the Local API connection and try again."; + showValidation([safeMessage]); + setText(elements.log, safeMessage); +} + +function renderPersistence(persistence = {}) { + setText(elements.persistenceSource, "Local API"); + setText(elements.persistenceStorage, persistence.storage === "server-owned" ? "Local DB" : "Local DB"); +} + +function createEditRow(action = null) { + const key = action?.key || NEW_ROW_KEY; + const row = document.createElement("tr"); + row.dataset.eventsActionEditor = key; + + const nameCell = document.createElement("td"); + nameCell.append(createInput(action?.name || "", "eventActionName", "Action")); + + const typeCell = document.createElement("td"); + typeCell.append(createSelect(action?.actionType || "", "eventActionType", EVENT_ACTION_OPTIONS, "Select action", "Event option")); + + const messageCell = document.createElement("td"); + messageCell.append(createSelect(action?.messageKey || "", "eventActionMessage", activeMessages(), "Select message", "Message")); + + const actions = document.createElement("td"); + actions.append(createActionGroup( + createButton("Save", "eventsActionCommit", key, { primary: true }), + createButton("Cancel", "eventsActionCancel", key), + )); + + row.append( + nameCell, + typeCell, + messageCell, + createCell(action ? formatUpdated(action.updatedAt) : "New"), + actions, + ); + return row; +} + +function createActionRow(action) { + const row = document.createElement("tr"); + row.dataset.eventsActionRow = action.key; + const actions = document.createElement("td"); + actions.append(createActionGroup( + createButton("Edit", "eventsActionEdit", action.key), + )); + row.append( + createRowHeader(action.name), + createCell(action.actionLabel || actionTypeDefinition(action.actionType)?.name || "Unknown action"), + createCell(action.messageName || "No message required"), + createCell(formatUpdated(action.updatedAt)), + actions, + ); + return row; +} + +function renderRows() { + if (!elements.table) { + return; + } + elements.table.replaceChildren(); + if (state.editingKey === NEW_ROW_KEY) { + elements.table.append(createEditRow(null)); + } + if (!state.eventActions.length && state.editingKey !== NEW_ROW_KEY) { + elements.table.append(tableMessage("No event actions yet. Add an action when your event flow is ready.")); + return; + } + state.eventActions.forEach((action) => { + if (state.editingKey === action.key) { + elements.table.append(createEditRow(action)); + return; + } + elements.table.append(createActionRow(action)); + }); +} + +function editorValue(root, selector) { + return root?.querySelector(selector)?.value || ""; +} + +function actionValues(key) { + const root = elements.table?.querySelector(`[data-events-action-editor="${key}"]`); + const existing = state.eventActions.find((action) => action.key === key) || null; + return { + active: existing ? existing.active : true, + actionType: editorValue(root, "[data-event-action-type]"), + messageKey: editorValue(root, "[data-event-action-message]"), + name: editorValue(root, "[data-event-action-name]"), + }; +} + +function validateAction(values) { + const errors = []; + const actionDefinition = actionTypeDefinition(values.actionType); + if (!values.name.trim()) { + errors.push("Action is required."); + } + if (!actionDefinition) { + errors.push("Event option is required."); + } else if (actionDefinition.requiresMessage && !values.messageKey) { + errors.push("Message is required for this event option."); + } else if (!actionDefinition.requiresMessage && values.messageKey) { + errors.push("Message is not used by this event option."); + } + return errors; +} + +function loadAll() { + const messagePayload = readData("/messages/messages", "Messages list"); + const actionPayload = readData("/messages/event-actions", "Message event actions"); + state.messages = messagePayload.messages || []; + state.eventActions = actionPayload.eventActions || []; + renderPersistence(actionPayload.persistence || messagePayload.persistence); + renderRows(); + setText(elements.log, "Events loaded."); +} + +function reloadAfterChange() { + loadAll(); + renderRows(); +} + +function beginAddAction() { + clearValidation(); + state.editingKey = NEW_ROW_KEY; + renderRows(); + setText(elements.log, "Ready to add an event action."); +} + +function beginEditAction(key) { + clearValidation(); + state.editingKey = key; + renderRows(); + setText(elements.log, "Event action row opened inline."); +} + +function cancelEdit() { + state.editingKey = ""; + clearValidation(); + renderRows(); + setText(elements.log, "Event action edit canceled."); +} + +function commitAction(key) { + const values = actionValues(key); + const errors = validateAction(values); + if (errors.length) { + showValidation(errors); + setText(elements.log, "Event action needs required fields."); + return; + } + clearValidation(); + try { + const result = key === NEW_ROW_KEY + ? writeData("/messages/event-actions", values, "Create message event action") + : writeData(`/messages/event-actions/${encodeURIComponent(key)}`, values, "Update message event action"); + state.editingKey = ""; + reloadAfterChange(); + setText(elements.log, `Saved event action ${result.eventAction.name}.`); + } catch { + showCreatorSafeFailure("Event action was not saved. Check required fields and try again."); + } +} + +elements.addAction?.addEventListener("click", () => { + beginAddAction(); +}); + +elements.table?.addEventListener("click", (event) => { + const editButton = event.target.closest("[data-events-action-edit]"); + const commitButton = event.target.closest("[data-events-action-commit]"); + const cancelButton = event.target.closest("[data-events-action-cancel]"); + if (editButton) { + beginEditAction(editButton.dataset.eventsActionEdit); + return; + } + if (commitButton) { + commitAction(commitButton.dataset.eventsActionCommit); + return; + } + if (cancelButton) { + cancelEdit(); + } +}); + +try { + loadAll(); +} catch { + showCreatorSafeFailure("Events could not load message event actions. Start the Local API and reload this tool."); +} diff --git a/toolbox/events/index.html b/toolbox/events/index.html index e0af2aa97..c3e20a6a2 100644 --- a/toolbox/events/index.html +++ b/toolbox/events/index.html @@ -18,7 +18,7 @@
Toolbox / Events

Events

-

Plan gameplay events, triggers, and state transitions. Static wireframe only; no database, persistence, save, load, or runtime behavior is implemented.

+

Plan gameplay events, triggers, and state transitions.

@@ -31,13 +31,50 @@

Events

Setup -

Not implemented yet.

+
+

Message event actions are saved through the Local API.

+

Use message references for authored text and speech flows.

+
-

Workspace

-

Plan gameplay events, triggers, and state transitions. This page preserves the shared Theme V2 tool template structure for future rebuild work.

+
+

Message Event Actions

+

Connect gameplay events to authored Messages without storing event data in the browser.

+
+
+
+
+
Local API Event References
+

Event Actions

+
+
+ +
+
+
+ + + + + + + + + + + + + +
ActionEvent OptionMessageUpdatedActions
Loading event actions.
+
+ +
+
@@ -57,6 +98,7 @@

Inspector

+ diff --git a/toolbox/messages/index.html b/toolbox/messages/index.html index 363fe4a34..252654778 100644 --- a/toolbox/messages/index.html +++ b/toolbox/messages/index.html @@ -39,7 +39,7 @@

Studio Setup

Row Workflow
-

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

+

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

Disable rows instead of deleting game text.

@@ -48,32 +48,35 @@

Studio Setup

Message Studio

-

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

+

Messages own game text and sentence order. Text To Speech owns profiles, voices, and emotion audio settings.

0Messages
-
0Message Parts
-
0TTS Profiles
+
0Sentences
+
0Referenced
-
-
Game Text Repository
-

Messages

+
+
+
Game Text Repository
+

Messages

+
+
+ +
- - - + - +
MessageTypeStatusParts TTS ProfileUpdated Actions
Loading message rows.
Loading message rows.
@@ -94,34 +97,53 @@

Inspector

Name: None

Emotion: None

-

Part: None

+

Sentence: None

Status: None

Text:

No message selected.
- Persistence + Reference Usage
-

Source: Local API

-

Database Direction: Postgres

-

Ownership: messages

+

Usage Count: 0

+
    +
  • Select a message to view references.
  • +
- Playback + Speech Preview +
+ +

Status: Idle

+
+
+
+ Publish Validation
- +
-
Loading playback engine.
+

Status: Not checked

+
    +
  • Run validation before publishing.
  • +
+
+
+
+ Persistence +
+

Source: Local API

+

Database Direction: Local DB

+

Ownership: messages

Future Compatibility
-

Message parts are ordered text with selected Emotion and TTS Profile values.

-

This tool stores message text exactly as entered.

+

Message rows reference Text To Speech profiles.

+

Sentence rows reference emotions from the selected TTS Profile.

diff --git a/toolbox/messages/messages-api-client.js b/toolbox/messages/messages-api-client.js index 333460609..50cf7773d 100644 --- a/toolbox/messages/messages-api-client.js +++ b/toolbox/messages/messages-api-client.js @@ -17,6 +17,15 @@ function writeData(path, body, context) { ); } +function actionData(path, context) { + return requireServerApiData( + safeRequestServerApi(path, { + method: "POST", + }), + context, + ); +} + export function listEmotionProfiles() { return readData("/messages/emotion-profiles", "Messages emotion profiles"); } @@ -53,6 +62,10 @@ export function listMessages() { return readData("/messages/messages", "Messages list"); } +export function validatePublishConfiguration() { + return readData("/messages/publish-validation", "Messages publish validation"); +} + export function getMessage(messageKey) { return readData(`/messages/messages/${encodeURIComponent(messageKey)}`, "Message record"); } @@ -65,6 +78,10 @@ export function updateMessage(messageKey, input) { return writeData(`/messages/messages/${encodeURIComponent(messageKey)}`, input, "Update message"); } +export function deleteMessage(messageKey) { + return actionData(`/messages/messages/${encodeURIComponent(messageKey)}/delete`, "Delete message"); +} + export function listMessageSegments() { return readData("/messages/segments", "Message segments list"); } @@ -80,3 +97,7 @@ export function createMessageSegment(input) { export function updateMessageSegment(segmentKey, input) { return writeData(`/messages/segments/${encodeURIComponent(segmentKey)}`, input, "Update message segment"); } + +export function deleteMessageSegment(segmentKey) { + return actionData(`/messages/segments/${encodeURIComponent(segmentKey)}/delete`, "Delete message segment"); +} diff --git a/toolbox/messages/messages.js b/toolbox/messages/messages.js index 5fdd0a47b..72c339f58 100644 --- a/toolbox/messages/messages.js +++ b/toolbox/messages/messages.js @@ -1,70 +1,73 @@ +import { + createMessageStudioDefaultTtsProfiles, + createMessageStudioTtsProfileOptions, +} from "../text-to-speech/text2speech.js"; import { createMessage, createMessageSegment, + deleteMessage, + deleteMessageSegment, listEmotionProfiles, listMessages, listMessageSegments, listTtsProfiles, updateMessage, updateMessageSegment, + validatePublishConfiguration, } 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_EMOTION_SETTINGS = Object.freeze([ - Object.freeze({ active: true, emotion: "calm", emotionLabel: "Calm", pitch: 1, rate: 1, ssmlLikePreset: "normal", volume: 1 }), - Object.freeze({ active: true, emotion: "urgent", emotionLabel: "Urgent", pitch: 1.08, rate: 1.15, ssmlLikePreset: "normal", volume: 1 }), - Object.freeze({ active: true, emotion: "whisper", emotionLabel: "Whisper", pitch: 0.95, rate: 0.9, ssmlLikePreset: "normal", volume: 0.55 }), - Object.freeze({ active: true, emotion: "angry", emotionLabel: "Angry", pitch: 0.98, rate: 1.1, ssmlLikePreset: "normal", volume: 1 }), -]); -const DEFAULT_TTS_PROFILE = Object.freeze({ - active: true, - description: "Balanced local browser playback option until authored TTS profiles are available.", - emotionSettings: DEFAULT_TTS_EMOTION_SETTINGS, - key: DEFAULT_TTS_PROFILE_KEY, - language: "en-US", - name: "Default Balanced TTS Profile", - pitch: 1, - providerKey: "browser-speech", - rate: 1, - voiceName: "", - volume: 1, +const MESSAGE_TABLE_COLSPAN = 4; +const TTS_PROVIDER_REGISTRY = Object.freeze({ + "azure": Object.freeze({ activeRuntime: false, label: "Azure", requiresConfig: true }), + "browser-speech": Object.freeze({ activeRuntime: true, label: "Browser Speech API", requiresConfig: false }), + "elevenlabs": Object.freeze({ activeRuntime: false, label: "ElevenLabs", requiresConfig: true }), + "openai": Object.freeze({ activeRuntime: false, label: "OpenAI", requiresConfig: true }), + "polly": Object.freeze({ activeRuntime: false, label: "Polly", requiresConfig: true }), }); -const ttsServiceRegistry = createMessageStudioTtsServiceRegistry(); +const MESSAGE_STUDIO_TTS_PROFILE_CONTRACT = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) + .map((profile) => Object.freeze({ + ...profile, + emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), + }))); const elements = { + addMessage: document.querySelector("[data-messages-add-row]"), count: document.querySelector("[data-messages-count]"), log: document.querySelector("[data-messages-log]"), persistenceEngine: document.querySelector("[data-messages-persistence-engine]"), persistenceOwner: document.querySelector("[data-messages-persistence-owner]"), persistenceSource: document.querySelector("[data-messages-persistence-source]"), - playbackStatus: document.querySelector("[data-messages-playback-status]"), + publishIssues: document.querySelector("[data-messages-publish-issues]"), + publishStatus: document.querySelector("[data-messages-publish-status]"), + publishValidate: document.querySelector("[data-messages-publish-validate]"), + referencedCount: document.querySelector("[data-messages-referenced-count]"), selectedEmotion: document.querySelector("[data-messages-selected-emotion]"), selectedName: document.querySelector("[data-messages-selected-name]"), selectedSegment: document.querySelector("[data-messages-selected-segment]"), selectedStatus: document.querySelector("[data-messages-selected-status]"), selectedText: document.querySelector("[data-messages-selected-text]"), segmentCount: document.querySelector("[data-messages-segment-count]"), + speechStatus: document.querySelector("[data-messages-speech-status]"), stopSpeech: document.querySelector("[data-messages-stop-speech]"), table: document.querySelector("[data-messages-table]"), - ttsCount: document.querySelector("[data-messages-tts-count]"), + usageCount: document.querySelector("[data-messages-usage-count]"), validationCard: document.querySelector("[data-messages-validation-card]"), validationErrors: document.querySelector("[data-messages-validation-errors]"), + referenceList: document.querySelector("[data-messages-reference-list]"), }; const state = { editingMessageKey: "", editingSegmentKey: "", + editingSegmentMessageKey: "", emotionProfiles: [], - messageTtsProfileKeys: new Map(), messages: [], - segmentTtsProfileKeys: new Map(), + publishValidation: null, segments: [], selectedMessageKey: "", selectedSegmentKey: "", - ttsServices: [], - ttsProfiles: [], + voiceProfiles: [], }; function setText(element, value) { @@ -73,22 +76,34 @@ function setText(element, value) { } } -function statusForActive(active) { - return active ? "Active" : "Inactive"; -} - function createCell(text) { const cell = document.createElement("td"); cell.textContent = text; return cell; } -function createButton(label, dataName, value) { +function createRowHeader(text) { + const cell = document.createElement("th"); + cell.scope = "row"; + cell.textContent = text; + return cell; +} + +function createButton(label, dataName, value, options = {}) { const button = document.createElement("button"); - button.className = "btn btn--compact"; + button.className = options.primary ? "btn btn--compact primary" : "btn btn--compact"; button.type = "button"; button.dataset[dataName] = value; button.textContent = label; + if (options.disabled) { + button.disabled = true; + } + if (options.title) { + button.title = options.title; + } + if (options.ariaLabel) { + button.setAttribute("aria-label", options.ariaLabel); + } return button; } @@ -99,49 +114,40 @@ function createActionGroup(...buttons) { return group; } -function createInput(value, dataName, type = "text") { +function createInput(value, dataName, ariaLabel) { const input = document.createElement("input"); input.dataset[dataName] = ""; - input.type = type; + input.type = "text"; input.value = value ?? ""; + input.setAttribute("aria-label", ariaLabel); return input; } -function createNumberInput(value, dataName, options = {}) { - const input = createInput(String(value ?? ""), dataName, "number"); +function createNumberInput(value, dataName, ariaLabel, options = {}) { + const input = createInput(String(value ?? ""), dataName, ariaLabel); + input.type = "number"; if (options.min !== undefined) { input.min = String(options.min); } - if (options.max !== undefined) { - input.max = String(options.max); - } if (options.step !== undefined) { input.step = String(options.step); } return input; } -function createCheckbox(checked, dataName) { - const label = document.createElement("label"); - const input = document.createElement("input"); - input.dataset[dataName] = ""; - input.type = "checkbox"; - input.checked = checked !== false; - label.append(input, " Active"); - return label; -} - -function createTextarea(value, dataName, rows = 4) { +function createTextarea(value, dataName, ariaLabel) { const textarea = document.createElement("textarea"); textarea.dataset[dataName] = ""; - textarea.rows = rows; + textarea.rows = 3; textarea.value = value ?? ""; + textarea.setAttribute("aria-label", ariaLabel); return textarea; } -function createSelect(value, dataName, options, placeholder) { +function createSelect(value, dataName, options, placeholder, ariaLabel) { const select = document.createElement("select"); select.dataset[dataName] = ""; + select.setAttribute("aria-label", ariaLabel); const placeholderOption = document.createElement("option"); placeholderOption.value = ""; placeholderOption.textContent = placeholder; @@ -156,27 +162,6 @@ 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 createField(labelText, field) { - const label = document.createElement("label"); - const span = document.createElement("span"); - span.textContent = labelText; - label.append(span, field); - return label; -} - function tableMessage(colSpan, text) { const row = document.createElement("tr"); const cell = document.createElement("td"); @@ -186,27 +171,6 @@ function tableMessage(colSpan, text) { return row; } -function tableActionRow(colSpan, button) { - const row = document.createElement("tr"); - const cell = document.createElement("td"); - cell.colSpan = colSpan; - cell.append(createActionGroup(button)); - row.append(cell); - return row; -} - -function createMessageAddControlRow() { - const row = tableActionRow(6, createButton("Add Message", "messagesAddRow", NEW_ROW_KEY)); - row.dataset.messagesAddControlRow = ""; - return row; -} - -function createSegmentAddControlRow() { - const row = tableActionRow(5, createButton("Add Part", "messagesSegmentAddRow", state.selectedMessageKey)); - row.dataset.messagesSegmentAddControlRow = state.selectedMessageKey; - return row; -} - function clearValidation() { if (elements.validationErrors) { elements.validationErrors.replaceChildren(); @@ -229,84 +193,119 @@ function showValidation(errors) { elements.validationCard.hidden = errors.length === 0; } -function activeEmotionProfiles() { - return state.emotionProfiles.filter((profile) => profile.active); -} - -function selectedMessage() { - return state.messages.find((message) => message.key === state.selectedMessageKey) || null; +function showCreatorSafeFailure(message) { + const safeMessage = message || "Message Studio could not finish that action. Check the Local API connection and try again."; + showValidation([safeMessage]); + setText(elements.log, safeMessage); } -function selectedSegment() { - return state.segments.find((segment) => segment.key === state.selectedSegmentKey) || null; +function normalizedLookupKey(value) { + return String(value || "").trim().toLowerCase(); } -function emotionProfileByKey(profileKey) { - return state.emotionProfiles.find((profile) => profile.key === profileKey) || null; +function apiTtsProfileByContractName(apiProfiles = []) { + return new Map(apiProfiles.map((profile) => [normalizedLookupKey(profile.name), profile])); } -function emotionSettingKey(value) { - return String(value || "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "neutral"; +function apiEmotionSettingForContract(apiProfile, contractSetting) { + const settings = Array.isArray(apiProfile?.emotionSettings) ? apiProfile.emotionSettings : []; + const label = normalizedLookupKey(contractSetting.emotionLabel || contractSetting.name || contractSetting.emotion); + const emotion = normalizedLookupKey(contractSetting.emotion); + return settings.find((setting) => normalizedLookupKey(setting.emotionLabel || setting.name) === label) + || settings.find((setting) => normalizedLookupKey(setting.emotion) === emotion) + || null; } -function selectedEmotionSettingForProfile(profile, emotionProfile) { - const settings = Array.isArray(profile?.emotionSettings) - ? profile.emotionSettings.filter((setting) => setting?.active !== false) - : []; - const selectedEmotionKey = emotionSettingKey(emotionProfile?.name); - const setting = settings.find((candidate) => ( - emotionSettingKey(candidate.emotion) === selectedEmotionKey - || emotionSettingKey(candidate.emotionLabel) === selectedEmotionKey - )); - if (!setting) { +function messageStudioTtsProfilesFromContract(apiProfiles = []) { + const apiByName = apiTtsProfileByContractName(apiProfiles); + return MESSAGE_STUDIO_TTS_PROFILE_CONTRACT.map((contractProfile) => { + const apiProfile = apiByName.get(normalizedLookupKey(contractProfile.name)) || null; return { - message: `Selected TTS Profile "${profile?.name || "Unknown"}" does not include an Emotion Setting for "${emotionProfile?.name || "Unknown"}".`, - ok: false, + ...contractProfile, + active: apiProfile ? apiProfile.active !== false : contractProfile.active !== false, + age: contractProfile.age || apiProfile?.age || "", + ageFilter: contractProfile.ageFilter || apiProfile?.ageFilter || apiProfile?.age || "", + emotionSettings: contractProfile.emotionSettings + .map((contractSetting) => { + const apiSetting = apiEmotionSettingForContract(apiProfile, contractSetting); + const emotionProfile = emotionProfileByLabel(contractSetting.emotionLabel || apiSetting?.emotionLabel || contractSetting.emotion); + return { + ...contractSetting, + active: apiSetting ? apiSetting.active !== false : contractSetting.active !== false, + key: emotionProfile?.key || apiSetting?.key || contractSetting.key, + }; + }) + .filter((setting) => setting.key && setting.active !== false), + gender: contractProfile.gender || apiProfile?.gender || "", + key: apiProfile?.key || contractProfile.key, + language: contractProfile.language || apiProfile?.language || "", + providerKey: contractProfile.providerKey || apiProfile?.providerKey || "browser-speech", + voice: contractProfile.voice || apiProfile?.voice || apiProfile?.voiceName || "", + voiceName: contractProfile.voiceName || apiProfile?.voiceName || "", }; - } - return { ok: true, setting }; + }); } -function activeTtsProfileOptions() { - const activeProfiles = state.ttsProfiles.filter((profile) => profile.active); - return activeProfiles.length ? activeProfiles : [DEFAULT_TTS_PROFILE]; +function activeVoiceProfiles() { + return state.voiceProfiles.filter((profile) => profile.active); } -function defaultTtsProfileKey() { - return activeTtsProfileOptions()[0]?.key || DEFAULT_TTS_PROFILE_KEY; +function voiceProfileByKey(profileKey) { + return state.voiceProfiles.find((profile) => profile.key === profileKey) || null; } -function ttsProfileOptionByKey(profileKey) { - const activeProfiles = activeTtsProfileOptions(); - if (!profileKey) { - return activeProfiles[0] || DEFAULT_TTS_PROFILE; +function ttsProfileByKey(profileKey) { + return voiceProfileByKey(profileKey); +} + +function voiceOptionsWithCurrent(currentKey) { + const active = activeVoiceProfiles(); + const current = voiceProfileByKey(currentKey); + if (current && !active.some((profile) => profile.key === current.key)) { + return [...active, current]; } - return activeProfiles.find((profile) => profile.key === profileKey) || null; + return active; } -function selectedTtsProfileForMessage(messageKey) { - const profileKey = state.messageTtsProfileKeys.get(messageKey); - return ttsProfileOptionByKey(profileKey || defaultTtsProfileKey()); +function emotionOptionsForTtsProfile(profileKey) { + const profile = ttsProfileByKey(profileKey); + const settings = Array.isArray(profile?.emotionSettings) ? profile.emotionSettings : []; + return settings + .filter((setting) => setting.active !== false) + .map((setting) => ({ + key: setting.key || emotionProfileByLabel(setting.emotionLabel || setting.emotion)?.key || setting.emotion, + name: setting.emotionLabel || setting.name || setting.emotion, + })) + .filter((setting) => setting.key && setting.name); } -function selectedTtsProfileForSegment(segmentKey, messageKey = state.selectedMessageKey) { - const segmentProfileKey = state.segmentTtsProfileKeys.get(segmentKey); - if (segmentProfileKey) { - return ttsProfileOptionByKey(segmentProfileKey); - } - const messageProfileKey = state.messageTtsProfileKeys.get(messageKey); - if (messageProfileKey) { - return ttsProfileOptionByKey(messageProfileKey); +function emotionProfileByLabel(label) { + const normalized = String(label || "").trim().toLowerCase(); + if (!normalized) { + return null; } - return ttsProfileOptionByKey(defaultTtsProfileKey()); + return state.emotionProfiles.find((profile) => String(profile.name || "").trim().toLowerCase() === normalized) || null; +} + +function emotionSettingForKey(profileKey, emotionKey) { + const profile = ttsProfileByKey(profileKey); + const settings = Array.isArray(profile?.emotionSettings) ? profile.emotionSettings : []; + return settings.find((candidate) => candidate.key === emotionKey) + || settings.find((candidate) => candidate.emotion === emotionKey) + || null; +} + +function profileEmotionKeyOrDefault(profileKey, currentKey = "") { + const options = emotionOptionsForTtsProfile(profileKey); + return options.some((option) => option.key === currentKey) ? currentKey : options[0]?.key || ""; +} + +function selectedMessage() { + return state.messages.find((message) => message.key === state.selectedMessageKey) || null; } -function selectedTtsService() { - return state.ttsServices.find((service) => service.available) || null; +function selectedSegment() { + return state.segments.find((segment) => segment.key === state.selectedSegmentKey) || null; } function messageSegments(messageKey) { @@ -315,36 +314,49 @@ function messageSegments(messageKey) { .sort((left, right) => left.displayOrder - right.displayOrder || left.createdAt.localeCompare(right.createdAt) || left.key.localeCompare(right.key)); } -function selectedMessageSegments() { - return messageSegments(state.selectedMessageKey); -} - -function nextSegmentOrder() { - const segments = selectedMessageSegments(); +function nextSegmentOrder(messageKey) { + const segments = messageSegments(messageKey); if (!segments.length) { return 1; } - return Math.max(...segments.map((segment) => segment.displayOrder)) + 1; + return Math.max(...segments.map((segment) => Number(segment.displayOrder) || 0)) + 1; } -function selectOptionsWithCurrent(currentKey) { - const active = activeEmotionProfiles(); - const current = emotionProfileByKey(currentKey); - if (current && !active.some((profile) => profile.key === current.key)) { - return [...active, current]; +function messageReferenceCount(messageKey) { + return messageSegments(messageKey).length; +} + +function isMessageReferenced(messageKey) { + return messageReferenceCount(messageKey) > 0; +} + +function tagsForMessage(message) { + return message?.categoryName || "No tags"; +} + +function formatUpdated(value) { + const date = new Date(value || ""); + if (Number.isNaN(date.getTime())) { + return "Unknown"; } - return active; + return date.toLocaleString(undefined, { + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + month: "short", + year: "numeric", + }); } function renderCounts() { setText(elements.count, String(state.messages.length)); setText(elements.segmentCount, String(state.segments.length)); - setText(elements.ttsCount, String(activeTtsProfileOptions().length)); + setText(elements.referencedCount, String(state.messages.filter((message) => isMessageReferenced(message.key)).length)); } function renderPersistence(persistence = {}) { setText(elements.persistenceSource, "Local API"); - setText(elements.persistenceEngine, "Postgres target"); + setText(elements.persistenceEngine, "Local DB target"); setText(elements.persistenceOwner, persistence.owner || "messages"); } @@ -352,364 +364,685 @@ function renderSelectedMessage() { const selected = selectedMessage(); const segment = selectedSegment(); setText(elements.selectedName, selected?.name || "None"); - setText(elements.selectedEmotion, selected?.emotionProfileName || "None"); - setText(elements.selectedSegment, segment ? `${segment.displayOrder}: ${segment.segmentText}` : "None"); - setText(elements.selectedStatus, selected ? statusForActive(selected.active) : "None"); + setText(elements.selectedEmotion, segment?.emotionProfileName || selected?.emotionProfileName || "None"); + setText(elements.selectedSegment, segment ? `Sentence ${segment.displayOrder}` : selected ? `${messageReferenceCount(selected.key)} sentence${messageReferenceCount(selected.key) === 1 ? "" : "s"}` : "None"); + setText(elements.selectedStatus, selected ? `${selected.active === false ? "Archived" : "Active"} / ${isMessageReferenced(selected.key) ? "Referenced" : "Unreferenced"}` : "None"); setText(elements.selectedText, segment?.segmentText || selected?.messageText || "No message selected."); } -function refreshTtsServices() { - state.ttsServices = ttsServiceRegistry.listServices(); +function renderReferenceUsage() { + if (!elements.referenceList) { + return; + } + const selected = selectedMessage(); + const references = selected ? messageSegments(selected.key) : []; + setText(elements.usageCount, String(references.length)); + elements.referenceList.replaceChildren(); + if (!selected) { + const item = document.createElement("li"); + item.textContent = "Select a message to view references."; + elements.referenceList.append(item); + return; + } + if (!references.length) { + const item = document.createElement("li"); + item.textContent = "This message has no sentences yet."; + elements.referenceList.append(item); + return; + } + references.forEach((reference) => { + const item = document.createElement("li"); + const snippet = reference.segmentText ? `: ${reference.segmentText}` : ""; + item.textContent = `Sentence ${reference.displayOrder}${snippet}`; + elements.referenceList.append(item); + }); +} + +function renderPublishValidation() { + if (!elements.publishIssues || !elements.publishStatus) { + return; + } + elements.publishIssues.replaceChildren(); + if (!state.publishValidation) { + setText(elements.publishStatus, "Not checked"); + const item = document.createElement("li"); + item.textContent = "Run validation before publishing."; + elements.publishIssues.append(item); + return; + } + if (state.publishValidation.valid) { + setText(elements.publishStatus, "Ready"); + const item = document.createElement("li"); + item.textContent = "No message publish issues."; + elements.publishIssues.append(item); + return; + } + setText(elements.publishStatus, "Blocked"); + const issues = Array.isArray(state.publishValidation.issues) ? state.publishValidation.issues : []; + if (!issues.length) { + const item = document.createElement("li"); + item.textContent = "Message publish validation is blocked. Review messages and profile references."; + elements.publishIssues.append(item); + return; + } + issues.forEach((issue) => { + const item = document.createElement("li"); + const targetName = issue.targetName ? `${issue.targetName}: ` : ""; + item.textContent = `${targetName}${issue.message || "Review this message before publishing."}`; + elements.publishIssues.append(item); + }); +} + +function speechRuntime() { + const synthesis = window.speechSynthesis; + const Utterance = window.SpeechSynthesisUtterance; + if (!synthesis || !Utterance) { + throw new Error("Browser Speech API is unavailable."); + } + return { synthesis, Utterance }; +} + +function profilePreferenceLabel(value, fallback = "Any") { + const normalized = String(value || "").trim(); + if (!normalized) { + return fallback; + } + const labels = new Map([ + ["adult", "Adult"], + ["any", "Any"], + ["child", "Child"], + ["elderly", "Elderly"], + ["female", "Female"], + ["female-preferred", "Female"], + ["male", "Male"], + ["male-preferred", "Male"], + ["neutral", "Neutral"], + ["teen", "Teen"], + ]); + const key = normalized.toLowerCase(); + if (labels.has(key)) { + return labels.get(key); + } + return normalized + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function profileValue(value, fallback) { + const normalized = String(value || "").trim(); + return normalized || fallback; +} + +function playbackProfileDetails(profile) { + return { + ageFilter: profilePreferenceLabel(profile?.ageFilter || profile?.voiceAge || profile?.age, "Any"), + gender: profilePreferenceLabel(profile?.gender || profile?.genderFilter || profile?.voiceGender, "Any"), + language: profileValue(profile?.language, "No language selected"), + profile: profileValue(profile?.name, "No TTS Profile"), + voice: profileValue(profile?.voiceName || profile?.voice, "No voice selected"), + }; +} + +function playbackDiagnostics(profile) { + const details = playbackProfileDetails(profile); + return [ + "Playing:", + `Profile: ${details.profile}`, + `Gender: ${details.gender}`, + `Voice: ${details.voice}`, + `Language: ${details.language}`, + `Age Filter: ${details.ageFilter}`, + ].join("\n"); } -function playbackReadinessMessage() { - refreshTtsServices(); - const service = selectedTtsService(); - if (!service) { - const unavailableService = state.ttsServices.find((candidate) => !candidate.available); - if (unavailableService) { - return unavailableService.unavailableMessage || "No TTS service is available in this browser."; +function playbackStatus(profile, label) { + const details = playbackProfileDetails(profile); + return `Speaking ${label}. Profile: ${details.profile}; Gender: ${details.gender}; Voice: ${details.voice}; Language: ${details.language}; Age Filter: ${details.ageFilter}.`; +} + +function browserVoiceForProfile(synthesis, voiceProfile) { + const voiceName = String(voiceProfile?.voiceName || "").trim(); + if (!voiceName) { + throw new Error("Select a voice for this TTS Profile before playing."); + } + if (voiceName === "Browser default") { + return null; + } + const voices = typeof synthesis.getVoices === "function" ? synthesis.getVoices() : []; + return voices.find((voice) => voice.name === voiceName || voice.voiceURI === voiceName) || null; +} + +function runtimeProviderForProfile(voiceProfile) { + const provider = TTS_PROVIDER_REGISTRY[voiceProfile?.providerKey || ""]; + if (!provider) { + throw new Error("Voice profile provider is not supported."); + } + if (!provider.activeRuntime || provider.requiresConfig) { + throw new Error("Voice profile provider is not active for browser playback."); + } + return provider; +} + +function speechItemFromMessage(message) { + const profileKey = message.voiceProfileKey || ""; + const emotionKey = message.emotionProfileKey || ""; + const voice = ttsProfileByKey(profileKey); + const emotion = emotionSettingForKey(profileKey, emotionKey); + return { + emotion, + emotionKey, + label: message.name, + profileKey, + text: message.messageText, + voice, + }; +} + +function speechItemFromSegment(segment) { + const message = state.messages.find((candidate) => candidate.key === segment.messageKey) || null; + const profileKey = message?.voiceProfileKey || ""; + const emotionKey = segment.emotionProfileKey || ""; + const emotion = emotionSettingForKey(profileKey, emotionKey); + const voice = ttsProfileByKey(profileKey); + return { + emotion, + emotionKey, + label: `${segment.messageName || "Message"} sentence ${segment.displayOrder}`, + profileKey, + text: segment.segmentText, + voice, + }; +} + +function selectedSpeechItems() { + const segment = selectedSegment(); + if (segment) { + return [speechItemFromSegment(segment)]; + } + const message = selectedMessage(); + if (!message) { + return []; + } + const segments = messageSegments(message.key); + return segments.length ? segments.map(speechItemFromSegment) : [speechItemFromMessage(message)]; +} + +function assertSpeechItem(item) { + if (!String(item?.text || "").trim()) { + throw new Error("Add sentence text before playback."); + } + if (!item.profileKey) { + throw new Error("Select a TTS Profile for this message before playing."); + } + if (!item.voice) { + throw new Error("Select a TTS Profile for this message before playing."); + } + if (!String(item.voice.voiceName || "").trim()) { + throw new Error("Select a voice for this TTS Profile before playing."); + } + if (!item.emotionKey) { + throw new Error("Select an emotion for this sentence before playing."); + } + if (!item.emotion) { + throw new Error("Add this emotion to the selected TTS Profile before playing."); + } + runtimeProviderForProfile(item.voice); +} + +function speechPlaybackGuidance(error) { + const message = error instanceof Error ? error.message : ""; + return message || "Speech playback could not start. Check the selected message, emotion, and TTS Profile."; +} + +function speakQueue(items, runtime, index = 0) { + if (index >= items.length) { + setText(elements.speechStatus, "Speech playback complete."); + return; + } + const item = items[index]; + try { + assertSpeechItem(item); + const utterance = new runtime.Utterance(item.text); + utterance.rate = Number(item.emotion.rate); + utterance.pitch = Number(item.emotion.pitch); + utterance.volume = Number(item.emotion.volume); + utterance.lang = item.voice.language || "en-US"; + utterance.voice = browserVoiceForProfile(runtime.synthesis, item.voice); + utterance.onend = () => speakQueue(items, runtime, index + 1); + utterance.onerror = () => { + showCreatorSafeFailure("Speech playback stopped before it finished. Check the selected profiles and try again."); + setText(elements.speechStatus, "Speech playback stopped."); + }; + if (index === 0) { + setText(elements.log, playbackDiagnostics(item.voice)); } - return "No TTS service is available in this browser."; + setText(elements.speechStatus, playbackStatus(item.voice, item.label)); + runtime.synthesis.speak(utterance); + } catch (error) { + showCreatorSafeFailure(speechPlaybackGuidance(error)); + setText(elements.speechStatus, "Speech playback unavailable."); + } +} + +function speakSelected() { + clearValidation(); + const items = selectedSpeechItems(); + if (!items.length) { + showCreatorSafeFailure("Select a message or sentence before starting speech playback."); + setText(elements.speechStatus, "No selection."); + return; + } + try { + const runtime = speechRuntime(); + runtime.synthesis.cancel(); + speakQueue(items, runtime); + } catch { + showCreatorSafeFailure("Browser speech playback is unavailable in this browser."); + setText(elements.speechStatus, "Speech playback unavailable."); } - return "Ready for Message Studio playback."; } -function renderPlaybackControls() { - setText(elements.playbackStatus, playbackReadinessMessage()); - if (elements.stopSpeech) { - elements.stopSpeech.disabled = !selectedTtsService()?.available; +function playMessage(key) { + clearValidation(); + state.selectedMessageKey = key; + state.selectedSegmentKey = ""; + const message = selectedMessage(); + if (!message) { + showCreatorSafeFailure("Select a message before starting speech playback."); + setText(elements.speechStatus, "No selection."); + return; + } + const segments = messageSegments(message.key); + if (!segments.length) { + showCreatorSafeFailure("Add at least one sentence before playing this message."); + setText(elements.speechStatus, "No sentences available."); + return; + } + const items = segments.map(speechItemFromSegment); + try { + const runtime = speechRuntime(); + runtime.synthesis.cancel(); + speakQueue(items, runtime); + } catch { + showCreatorSafeFailure("Browser speech playback is unavailable in this browser."); + setText(elements.speechStatus, "Speech playback unavailable."); } } -function createMessageEditRows(message = null) { +function playSentence(key) { + clearValidation(); + const segment = state.segments.find((candidate) => candidate.key === key); + if (!segment) { + showCreatorSafeFailure("Select a sentence before starting speech playback."); + setText(elements.speechStatus, "No selection."); + return; + } + state.selectedMessageKey = segment.messageKey; + state.selectedSegmentKey = segment.key; + try { + const runtime = speechRuntime(); + runtime.synthesis.cancel(); + speakQueue([speechItemFromSegment(segment)], runtime); + } catch { + showCreatorSafeFailure("Browser speech playback is unavailable in this browser."); + setText(elements.speechStatus, "Speech playback unavailable."); + } +} + +function stopSpeech() { + try { + window.speechSynthesis?.cancel?.(); + } catch { + showCreatorSafeFailure("Speech playback could not be stopped. Reload the tool and try again."); + return; + } + setText(elements.speechStatus, "Speech playback stopped."); + setText(elements.log, "Speech playback stopped."); +} + +function createMessageEditRow(message = null) { const key = message?.key || NEW_ROW_KEY; const row = document.createElement("tr"); row.dataset.messagesRowEditor = key; - const nameCell = document.createElement("td"); - nameCell.append(createInput(message?.name || "", "messageName")); + const messageCell = document.createElement("td"); + messageCell.append(createInput(message?.name || "", "messageName", "Message")); + + const profileCell = document.createElement("td"); + profileCell.append(createSelect( + message?.voiceProfileKey || "", + "messageTtsProfile", + voiceOptionsWithCurrent(message?.voiceProfileKey || ""), + "Select TTS profile", + "TTS Profile", + )); + + const actions = document.createElement("td"); + actions.append(createActionGroup( + createButton("Save", "messagesCommit", key, { primary: true }), + createButton("Cancel", "messagesCancel", key), + )); + + row.append( + messageCell, + profileCell, + createCell(message ? formatUpdated(message.updatedAt) : "New"), + actions, + ); + return row; +} - const typeCell = createCell(message?.categoryName || "Dialog"); +function createMessageSegmentEditRow(messageKey, segment = null) { + const key = segment?.key || NEW_ROW_KEY; + const row = document.createElement("tr"); + row.dataset.messagesSegmentEditor = key; + row.dataset.messagesSegmentMessage = messageKey; - const statusCell = document.createElement("td"); - statusCell.append(createCheckbox(message?.active !== false, "messageActive")); + const orderCell = document.createElement("td"); + orderCell.append(createNumberInput(segment?.displayOrder || nextSegmentOrder(messageKey), "segmentOrder", "Order", { min: 1, step: 1 })); - const partCell = createCell(message ? String(messageSegments(message.key).length) : "0"); + const textCell = document.createElement("td"); + textCell.append(createTextarea(segment?.segmentText || "", "segmentText", "Sentence text")); - const ttsCell = document.createElement("td"); - ttsCell.append(createTtsProfileSelect( - message ? selectedTtsProfileForMessage(message.key)?.key : defaultTtsProfileKey(), - "messageDefaultTtsProfile", - key, + const emotionCell = document.createElement("td"); + const message = state.messages.find((candidate) => candidate.key === messageKey) || null; + emotionCell.append(createSelect( + segment?.emotionProfileKey || "", + "segmentEmotion", + emotionOptionsForTtsProfile(message?.voiceProfileKey || segment?.voiceProfileKey || ""), + "Select emotion", + "Sentence emotion", )); const actions = document.createElement("td"); actions.append(createActionGroup( - createButton("Save", "messagesCommit", key), - createButton("Cancel", "messagesCancel", key), + createButton("Save", "messagesSegmentCommit", key, { primary: true }), + createButton("Cancel", "messagesSegmentCancel", key), )); - row.append(nameCell, typeCell, statusCell, partCell, ttsCell, actions); - - const detailRow = document.createElement("tr"); - detailRow.dataset.messagesRowEditorDetails = key; - const detailCell = document.createElement("td"); - detailCell.colSpan = 6; - const stack = document.createElement("div"); - stack.className = "content-stack"; - stack.append( - createField("Primary Emotion", createSelect(message?.emotionProfileKey || "", "messageEmotion", selectOptionsWithCurrent(message?.emotionProfileKey || ""), "Select emotion")), - createField("Message Text", createTextarea(message?.messageText || "", "messageText", 6)), - createField("Notes", createTextarea(message?.notes || "", "messageNotes", 3)), + row.append( + orderCell, + textCell, + emotionCell, + actions, ); - detailCell.append(stack); - detailRow.append(detailCell); + return row; +} - return [row, detailRow]; +function createMessageSegmentRow(segment) { + const row = document.createElement("tr"); + row.dataset.messagesSegmentRow = segment.key; + row.setAttribute("aria-selected", String(state.selectedSegmentKey === segment.key)); + const archiveButton = createButton(segment.active === false ? "Restore" : "Archive", "messagesSegmentArchive", segment.key, { + ariaLabel: `${segment.active === false ? "Restore" : "Archive"} sentence ${segment.displayOrder}`, + }); + const actions = document.createElement("td"); + actions.append(createActionGroup( + createButton("Play", "messagesSegmentPlay", segment.key, { + ariaLabel: `Play sentence ${segment.displayOrder}`, + }), + createButton("Edit", "messagesSegmentEdit", segment.key), + archiveButton, + createButton("Delete", "messagesSegmentDelete", segment.key), + )); + row.append( + createCell(segment.active === false ? `${segment.displayOrder} (Archived)` : String(segment.displayOrder)), + createCell(segment.segmentText || "No text"), + createCell(segment.emotionProfileName || "No emotion"), + actions, + ); + return row; } -function createMessageSegmentTable() { +function createMessagePartsTable(message) { const wrapper = document.createElement("div"); wrapper.className = "content-stack"; - const context = document.createElement("div"); - context.className = "kicker"; - context.textContent = "Message / Message Parts"; - const heading = document.createElement("h3"); - heading.textContent = "Message Parts"; - wrapper.append(context, heading); + const header = document.createElement("div"); + header.className = "surface-header"; + const title = document.createElement("div"); + const kicker = document.createElement("div"); + kicker.className = "kicker"; + kicker.textContent = "Message / Sentences"; + const heading = document.createElement("h4"); + heading.textContent = `${message.name} Sentences`; + title.append(kicker, heading); + const actions = document.createElement("div"); + actions.className = "action-group action-group--tight"; + actions.append(createButton("Add Sentence", "messagesSegmentAdd", message.key)); + header.append(title, actions); const tableWrapper = document.createElement("div"); tableWrapper.className = "table-wrapper"; const table = document.createElement("table"); - table.className = "data-table"; - table.setAttribute("aria-label", "Message parts"); + table.className = "data-table data-table--fixed"; + table.setAttribute("aria-label", `${message.name} Sentences`); const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); - ["Part Text", "Emotion", "TTS Profile", "Status", "Actions"].forEach((label) => { - const header = document.createElement("th"); - header.scope = "col"; - header.textContent = label; - headerRow.append(header); + ["Order", "Text", "Emotion", "Actions"].forEach((label) => { + const cell = document.createElement("th"); + cell.scope = "col"; + cell.textContent = label; + headerRow.append(cell); }); thead.append(headerRow); const tbody = document.createElement("tbody"); - tbody.dataset.messagesSegments = ""; + tbody.dataset.messagesSegmentTable = message.key; - const segments = selectedMessageSegments(); - if (!segments.length && state.editingSegmentKey !== NEW_ROW_KEY) { - tbody.append(tableMessage(5, "No message parts saved for this message.")); + const segments = messageSegments(message.key); + if (!segments.length && !(state.editingSegmentKey === NEW_ROW_KEY && state.editingSegmentMessageKey === message.key)) { + tbody.append(tableMessage(4, "No sentences yet. Add a sentence for this message when ready.")); } - - segments.forEach((segment, index) => { + segments.forEach((segment) => { if (state.editingSegmentKey === segment.key) { - tbody.append(createSegmentEditRow(segment)); + tbody.append(createMessageSegmentEditRow(message.key, segment)); return; } - const row = document.createElement("tr"); - row.dataset.messagesSegmentRow = segment.key; - const actions = document.createElement("td"); - const moveUp = createButton("Move Up", "messagesSegmentMoveUp", segment.key); - const moveDown = createButton("Move Down", "messagesSegmentMoveDown", segment.key); - moveUp.disabled = index === 0; - moveDown.disabled = index === segments.length - 1; - actions.append(createActionGroup( - 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(segment.segmentText), - createCell(segment.emotionProfileName || "Unknown"), - ttsCell, - createCell(statusForActive(segment.active)), - actions, - ); - tbody.append(row); + tbody.append(createMessageSegmentRow(segment)); }); - - if (state.editingSegmentKey === NEW_ROW_KEY) { - tbody.append(createSegmentEditRow(null)); - } else { - tbody.append(createSegmentAddControlRow()); + if (state.editingSegmentKey === NEW_ROW_KEY && state.editingSegmentMessageKey === message.key) { + tbody.append(createMessageSegmentEditRow(message.key, null)); } table.append(thead, tbody); tableWrapper.append(table); - wrapper.append(tableWrapper); + wrapper.append(header, tableWrapper); return wrapper; } -function createSegmentEditRow(segment = null) { - const key = segment?.key || NEW_ROW_KEY; - const row = document.createElement("tr"); - row.dataset.messagesSegmentEditor = key; - row.dataset.messagesSegmentOrder = String(segment?.displayOrder || nextSegmentOrder()); - - 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 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("Save", "messagesSegmentCommit", key), - createButton("Cancel", "messagesSegmentCancel", key), - )); - - row.append(textCell, emotionCell, ttsCell, statusCell, actions); - return row; -} - -function appendSelectedSegmentsHost(messageKey) { - if (state.selectedMessageKey !== messageKey) { +function appendMessagePartsHost(message) { + if (state.selectedMessageKey !== message.key) { return; } const hostRow = document.createElement("tr"); - hostRow.dataset.messagesSegmentHost = messageKey; + hostRow.dataset.messagesPartsHost = message.key; const cell = document.createElement("td"); - cell.colSpan = 6; - cell.append(createMessageSegmentTable()); + cell.colSpan = MESSAGE_TABLE_COLSPAN; + cell.append(createMessagePartsTable(message)); hostRow.append(cell); elements.table.append(hostRow); } +function createMessageRow(message) { + const row = document.createElement("tr"); + row.dataset.messagesRow = message.key; + row.tabIndex = 0; + row.setAttribute("aria-selected", String(state.selectedMessageKey === message.key)); + row.setAttribute("aria-expanded", String(state.selectedMessageKey === message.key)); + + const referenced = isMessageReferenced(message.key); + const deleteButton = createButton("Delete", "messagesDelete", message.key, { + ariaLabel: referenced ? `Delete disabled for referenced message ${message.name}` : `Delete ${message.name}`, + disabled: referenced, + title: referenced ? "Delete disabled: this message has sentences." : "Delete message", + }); + const archiveButton = createButton(message.active === false ? "Restore" : "Archive", "messagesArchive", message.key, { + ariaLabel: `${message.active === false ? "Restore" : "Archive"} ${message.name}`, + }); + const actions = document.createElement("td"); + actions.append(createActionGroup( + createButton("Play", "messagesPlay", message.key, { + ariaLabel: `Play ${message.name}`, + }), + createButton("Sentences", "messagesToggleParts", message.key, { + ariaLabel: `${state.selectedMessageKey === message.key ? "Hide" : "Show"} sentences for ${message.name}`, + }), + createButton("Edit", "messagesEdit", message.key), + archiveButton, + deleteButton, + )); + + row.append( + createRowHeader(message.active === false ? `${message.name} (Archived)` : message.name), + createCell(message.voiceProfileName || "No TTS profile"), + createCell(formatUpdated(message.updatedAt)), + actions, + ); + return row; +} + function renderMessageRows() { if (!elements.table) { return; } elements.table.replaceChildren(); + + if (state.editingMessageKey === NEW_ROW_KEY) { + elements.table.append(createMessageEditRow(null)); + } + if (!state.messages.length && state.editingMessageKey !== NEW_ROW_KEY) { - elements.table.append(tableMessage(6, "No messages saved yet. Add a message such as Bat Encounter.")); - elements.table.append(createMessageAddControlRow()); + elements.table.append(tableMessage(MESSAGE_TABLE_COLSPAN, "No messages yet. Add your first message when you are ready.")); return; } state.messages.forEach((message) => { if (state.editingMessageKey === message.key) { - createMessageEditRows(message).forEach((row) => elements.table.append(row)); - appendSelectedSegmentsHost(message.key); + elements.table.append(createMessageEditRow(message)); return; } - - 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.tabIndex = 0; - nameCell.setAttribute("role", "button"); - nameCell.setAttribute("aria-expanded", String(isExpanded)); - 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("Play Message", "messagesPlay", message.key), - createButton("Edit Message", "messagesEdit", message.key), - message.active ? createButton("Disable", "messagesDisable", message.key) : null, - )); - row.append( - nameCell, - createCell(message.categoryName || "Dialog"), - createCell(statusForActive(message.active)), - createCell(String(messageSegments(message.key).length)), - ttsCell, - actions, - ); - elements.table.append(row); - appendSelectedSegmentsHost(message.key); + elements.table.append(createMessageRow(message)); + appendMessagePartsHost(message); }); - - if (state.editingMessageKey === NEW_ROW_KEY) { - createMessageEditRows(null).forEach((row) => elements.table.append(row)); - } else { - elements.table.append(createMessageAddControlRow()); - } } function render(persistence = {}) { renderMessageRows(); renderSelectedMessage(); + renderReferenceUsage(); + renderPublishValidation(); renderCounts(); renderPersistence(persistence); - renderPlaybackControls(); } function editorValue(root, selector) { return root?.querySelector(selector)?.value || ""; } -function editorChecked(root, selector) { - return root?.querySelector(selector)?.checked !== false; -} - function messageValues(key) { const root = elements.table?.querySelector(`[data-messages-row-editor="${key}"]`); - const details = elements.table?.querySelector(`[data-messages-row-editor-details="${key}"]`); + const existing = state.messages.find((message) => message.key === key) || null; + const name = editorValue(root, "[data-message-name]"); + const voiceProfileKey = editorValue(root, "[data-message-tts-profile]"); + const emotionProfileKey = profileEmotionKeyOrDefault(voiceProfileKey, existing?.emotionProfileKey || ""); return { - active: editorChecked(root, "[data-message-active]"), - emotionProfileKey: editorValue(details, "[data-message-emotion]"), - messageText: editorValue(details, "[data-message-text]"), - name: editorValue(root, "[data-message-name]"), - notes: editorValue(details, "[data-message-notes]"), + active: existing ? existing.active : true, + emotionProfileKey, + messageText: existing?.messageText || name, + name, + notes: existing?.notes || "", + voiceProfileKey, }; } function validateMessage(values) { const errors = []; if (!values.name.trim()) { - errors.push("Message Name is required."); + errors.push("Message is required."); } - if (!values.emotionProfileKey) { - errors.push("Emotion is required."); + if (!values.voiceProfileKey) { + errors.push("TTS Profile is required."); } - if (!values.messageText.trim()) { - errors.push("Message Text is required."); + if (!values.emotionProfileKey) { + errors.push("The selected TTS Profile needs at least one emotion before saving a message."); } return errors; } function segmentValues(key) { const root = elements.table?.querySelector(`[data-messages-segment-editor="${key}"]`); + const existing = state.segments.find((segment) => segment.key === key) || null; + const messageKey = root?.dataset.messagesSegmentMessage || existing?.messageKey || state.selectedMessageKey; + const message = state.messages.find((candidate) => candidate.key === messageKey) || null; return { - active: editorChecked(root, "[data-segment-active]"), - displayOrder: editorValue(root, "[data-segment-order]") || root?.dataset.messagesSegmentOrder || String(nextSegmentOrder()), + active: existing ? existing.active : true, + displayOrder: editorValue(root, "[data-segment-order]"), emotionProfileKey: editorValue(root, "[data-segment-emotion]"), - messageKey: state.selectedMessageKey, + messageKey, segmentText: editorValue(root, "[data-segment-text]"), + voiceProfileKey: message?.voiceProfileKey || existing?.voiceProfileKey || "", }; } function validateSegment(values) { const errors = []; - if (!state.selectedMessageKey) { - errors.push("Select a message before adding parts."); + if (!values.messageKey) { + errors.push("Select a message before adding a sentence."); } if (!values.segmentText.trim()) { - errors.push("Part Text is required."); + errors.push("Sentence text is required."); } if (!values.emotionProfileKey) { errors.push("Emotion is required."); } - if (String(values.displayOrder).trim() === "") { - errors.push("Display Order is required."); - } else { - const displayOrder = Number(values.displayOrder); - if (!Number.isInteger(displayOrder) || displayOrder < 1) { - errors.push("Display Order must be a whole number of 1 or greater."); - } + const displayOrder = Number(values.displayOrder); + if (!Number.isInteger(displayOrder) || displayOrder < 1) { + errors.push("Order must be a whole number of 1 or greater."); } return errors; } +function markPublishValidationStale() { + state.publishValidation = null; +} + +async function runPublishValidation() { + clearValidation(); + try { + const result = validatePublishConfiguration(); + state.publishValidation = result.publishValidation || null; + renderPublishValidation(); + setText(elements.log, state.publishValidation?.valid + ? "Publish validation passed." + : "Publish blocked by message validation."); + } catch { + showCreatorSafeFailure("Publish validation could not run. Check the Local API connection and try again."); + } +} + async function loadAll() { const emotionPayload = listEmotionProfiles(); - const ttsPayload = listTtsProfiles(); const messagesPayload = listMessages(); const segmentsPayload = listMessageSegments(); + const voicePayload = listTtsProfiles(); state.emotionProfiles = emotionPayload.emotionProfiles || []; - state.ttsProfiles = ttsPayload.ttsProfiles || []; state.messages = messagesPayload.messages || []; state.segments = segmentsPayload.segments || []; + state.voiceProfiles = messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || []); if (state.selectedMessageKey && !state.messages.some((message) => message.key === state.selectedMessageKey)) { state.selectedMessageKey = ""; } - if (state.selectedSegmentKey && !state.segments.some((segment) => segment.key === state.selectedSegmentKey && segment.messageKey === state.selectedMessageKey)) { + if (state.selectedSegmentKey && !state.segments.some((segment) => segment.key === state.selectedSegmentKey)) { state.selectedSegmentKey = ""; } - render(messagesPayload.persistence || emotionPayload.persistence || ttsPayload.persistence || segmentsPayload.persistence); - setText(elements.log, "Message Studio loaded from the Local API."); + render(messagesPayload.persistence || emotionPayload.persistence || segmentsPayload.persistence || voicePayload.persistence); + setText(elements.log, "Message Studio loaded."); } -async function reloadAfterChange(messageKey = state.selectedMessageKey, segmentKey = state.selectedSegmentKey) { +async function reloadAfterChange(messageKey = state.selectedMessageKey) { + markPublishValidationStale(); await loadAll(); state.selectedMessageKey = messageKey && state.messages.some((message) => message.key === messageKey) ? messageKey : ""; - state.selectedSegmentKey = segmentKey && state.segments.some((segment) => segment.key === segmentKey && segment.messageKey === state.selectedMessageKey) ? segmentKey : ""; render(); } @@ -718,7 +1051,7 @@ async function commitMessage(key) { const errors = validateMessage(values); if (errors.length) { showValidation(errors); - setText(elements.log, "Message row update blocked by validation."); + setText(elements.log, "Message row needs required fields."); return; } clearValidation(); @@ -727,17 +1060,51 @@ async function commitMessage(key) { ? createMessage(values) : updateMessage(key, values); state.editingMessageKey = ""; - if (key === NEW_ROW_KEY) { - state.selectedMessageKey = ""; - state.selectedSegmentKey = ""; - } else { - state.selectedMessageKey = result.message.key; - } - await reloadAfterChange(state.selectedMessageKey, state.selectedSegmentKey); - setText(elements.log, `Updated row ${result.message.name}.`); + await reloadAfterChange(result.message.key); + setText(elements.log, `Saved message ${result.message.name}.`); + } catch { + showCreatorSafeFailure("Message was not saved. Check required fields and try again."); + } +} + +async function deleteMessageRecord(key) { + const message = state.messages.find((candidate) => candidate.key === key); + if (!message) { + return; + } + if (isMessageReferenced(key)) { + showCreatorSafeFailure("Delete is blocked because this message has sentences."); + return; + } + clearValidation(); + try { + deleteMessage(key); + state.editingMessageKey = ""; + state.selectedMessageKey = ""; + await reloadAfterChange(""); + setText(elements.log, `Deleted message ${message.name}.`); } catch (error) { - showValidation([error instanceof Error ? error.message : String(error || "Message row update failed.")]); - setText(elements.log, "Message row update failed."); + const messageText = error instanceof Error ? error.message : ""; + showCreatorSafeFailure( + messageText.toLowerCase().includes("referenced") + ? "Delete is blocked because this message has sentences." + : "Message was not deleted. Check the Local API connection and try again.", + ); + } +} + +async function archiveMessageRecord(key) { + const message = state.messages.find((candidate) => candidate.key === key); + if (!message) { + return; + } + clearValidation(); + try { + const result = updateMessage(key, { active: message.active === false }); + await reloadAfterChange(result.message.key); + setText(elements.log, `${result.message.active === false ? "Archived" : "Restored"} message ${result.message.name}.`); + } catch { + showCreatorSafeFailure("Message archive status was not changed. Check the Local API connection and try again."); } } @@ -746,286 +1113,178 @@ async function commitSegment(key) { const errors = validateSegment(values); if (errors.length) { showValidation(errors); - setText(elements.log, "Segment row update blocked by validation."); + setText(elements.log, "Sentence row needs required fields."); return; } clearValidation(); try { + const payload = { + ...values, + displayOrder: Number(values.displayOrder), + }; const result = key === NEW_ROW_KEY - ? createMessageSegment(values) - : updateMessageSegment(key, values); + ? createMessageSegment(payload) + : updateMessageSegment(key, payload); state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; + state.selectedMessageKey = result.segment.messageKey; state.selectedSegmentKey = result.segment.key; - await reloadAfterChange(state.selectedMessageKey, result.segment.key); - 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, "Message part update failed."); + await reloadAfterChange(result.segment.messageKey); + setText(elements.log, `Saved sentence ${result.segment.displayOrder}.`); + } catch { + showCreatorSafeFailure("Sentence was not saved. Check required fields and try again."); } } -async function disableMessage(key) { - const message = state.messages.find((candidate) => candidate.key === key); - if (!message) { +async function deleteSegmentRecord(key) { + const segment = state.segments.find((candidate) => candidate.key === key); + if (!segment) { return; } + clearValidation(); try { - const result = updateMessage(key, { - active: false, - emotionProfileKey: message.emotionProfileKey, - messageText: message.messageText, - name: message.name, - notes: message.notes, - }); - state.selectedMessageKey = result.message.key; - await reloadAfterChange(result.message.key); - setText(elements.log, `Disabled row ${result.message.name}.`); - } catch (error) { - showValidation([error instanceof Error ? error.message : String(error || "Message row status update failed.")]); - setText(elements.log, "Message row status update failed."); + deleteMessageSegment(key); + state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; + state.selectedSegmentKey = ""; + await reloadAfterChange(segment.messageKey); + setText(elements.log, `Deleted sentence ${segment.displayOrder}.`); + } catch { + showCreatorSafeFailure("Sentence was not deleted. Check the Local API connection and try again."); } } -async function disableSegment(key) { +async function archiveSegmentRecord(key) { const segment = state.segments.find((candidate) => candidate.key === key); if (!segment) { return; } + clearValidation(); try { const result = updateMessageSegment(key, { - active: false, + active: segment.active === false, displayOrder: segment.displayOrder, emotionProfileKey: segment.emotionProfileKey, messageKey: segment.messageKey, segmentText: segment.segmentText, + voiceProfileKey: segment.voiceProfileKey, }); + state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; + state.selectedMessageKey = result.segment.messageKey; state.selectedSegmentKey = result.segment.key; - await reloadAfterChange(segment.messageKey, result.segment.key); - 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, "Message part status update failed."); + await reloadAfterChange(result.segment.messageKey); + setText(elements.log, `${result.segment.active === false ? "Archived" : "Restored"} sentence ${result.segment.displayOrder}.`); + } catch { + showCreatorSafeFailure("Sentence archive status was not changed. Check the Local API connection and try again."); } } -function visiblePlaybackError(message) { - const safeMessage = message || "Message Studio playback failed. Check the selected message, part, and TTS profile."; - showValidation([safeMessage]); - setText(elements.playbackStatus, safeMessage); - setText(elements.log, safeMessage); - return { message: safeMessage, ok: false }; -} - -function playbackService() { - return selectedTtsService(); +function beginAddMessage() { + clearValidation(); + state.editingMessageKey = NEW_ROW_KEY; + state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; + state.selectedMessageKey = ""; + render(); + setText(elements.log, "Ready to add a message."); } -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 before playback."); - } - const emotionSetting = selectedEmotionSettingForProfile(profile, target.emotionProfile); - if (!emotionSetting.ok) { - return visiblePlaybackError(emotionSetting.message); - } - if (!String(target.text || "").trim()) { - return visiblePlaybackError("Selected message or part needs text before playback."); - } - return ttsServiceRegistry.speak(service.key, { - language: profile.language, - pitch: emotionSetting.setting.pitch ?? profile.pitch ?? 1, - rate: emotionSetting.setting.rate ?? profile.rate ?? 1, - speechItemId: target.id, - speechItemName: target.name, - ssmlLikePreset: emotionSetting.setting.ssmlLikePreset || "normal", - text: target.text, - voice: profile.voiceName, - volume: emotionSetting.setting.volume ?? profile.volume ?? 1, - }); +function beginEditMessage(key) { + clearValidation(); + state.editingMessageKey = key; + state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; + state.selectedMessageKey = key; + state.selectedSegmentKey = ""; + render(); + setText(elements.log, "Message row opened inline."); } -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 cancelMessageEdit() { + state.editingMessageKey = ""; + clearValidation(); + render(); + setText(elements.log, "Message row edit canceled."); } -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) { - return; - } +function beginAddSegment(messageKey) { clearValidation(); - const message = `Play Part queued ${target.label} using ${target.profile.name}.`; - setText(elements.playbackStatus, message); - setText(elements.log, message); + state.editingMessageKey = ""; + state.editingSegmentKey = NEW_ROW_KEY; + state.editingSegmentMessageKey = messageKey; + state.selectedMessageKey = messageKey; + state.selectedSegmentKey = ""; + render(); + setText(elements.log, "Ready to add a sentence."); } -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."); +function beginEditSegment(key) { + const segment = state.segments.find((candidate) => candidate.key === key); + if (!segment) { 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.playbackStatus, message); - setText(elements.log, message); + state.editingMessageKey = ""; + state.editingSegmentKey = key; + state.editingSegmentMessageKey = segment.messageKey; + state.selectedMessageKey = segment.messageKey; + state.selectedSegmentKey = key; + render(); + setText(elements.log, "Sentence row opened inline."); } -function stopSpeech() { - const result = ttsServiceRegistry.stop(); - if (!result.ok) { - visiblePlaybackError(result.message || "Message Studio playback could not be stopped."); - return; - } +function cancelSegmentEdit() { + state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; clearValidation(); - const message = `Message Studio playback stopped. Cleared ${result.stoppedCount} queued item${result.stoppedCount === 1 ? "" : "s"}.`; - setText(elements.playbackStatus, message); - setText(elements.log, message); + render(); + setText(elements.log, "Sentence row edit canceled."); } -async function moveSegment(key, direction) { - const segments = selectedMessageSegments(); - const currentIndex = segments.findIndex((segment) => segment.key === key); - const current = segments[currentIndex]; - const target = segments[currentIndex + direction]; - if (!current || !target) { - return; - } - try { - updateMessageSegment(current.key, { - active: current.active, - displayOrder: target.displayOrder, - emotionProfileKey: current.emotionProfileKey, - messageKey: current.messageKey, - segmentText: current.segmentText, - }); - updateMessageSegment(target.key, { - active: target.active, - displayOrder: current.displayOrder, - emotionProfileKey: target.emotionProfileKey, - messageKey: target.messageKey, - segmentText: target.segmentText, - }); - await reloadAfterChange(state.selectedMessageKey, current.key); - setText(elements.log, "Message part order updated."); - } catch (error) { - showValidation([error instanceof Error ? error.message : String(error || "Segment reorder failed.")]); - setText(elements.log, "Message part reorder failed."); - } -} +elements.addMessage?.addEventListener("click", () => { + beginAddMessage(); +}); elements.stopSpeech?.addEventListener("click", () => { stopSpeech(); }); -ttsServiceRegistry.onServicesChanged(() => { - renderPlaybackControls(); -}); - -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()); - } - renderPlaybackControls(); - 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()); - } - renderPlaybackControls(); - setText(elements.log, "TTS profile selected for this part playback session."); - } +elements.publishValidate?.addEventListener("click", () => { + runPublishValidation(); }); 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 messageAddButton = event.target.closest("[data-messages-add-row]"); - const messageNameCell = event.target.closest("[data-messages-name-cell]"); - const segmentRow = event.target.closest("[data-messages-segment-row]"); - const playButton = event.target.closest("[data-messages-play]"); + const togglePartsButton = event.target.closest("[data-messages-toggle-parts]"); 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 archiveButton = event.target.closest("[data-messages-archive]"); + const deleteButton = event.target.closest("[data-messages-delete]"); + const playButton = event.target.closest("[data-messages-play]"); + const segmentAddButton = event.target.closest("[data-messages-segment-add]"); 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]"); - const segmentDisableButton = event.target.closest("[data-messages-segment-disable]"); - const moveUpButton = event.target.closest("[data-messages-segment-move-up]"); - const moveDownButton = event.target.closest("[data-messages-segment-move-down]"); + const segmentArchiveButton = event.target.closest("[data-messages-segment-archive]"); + const segmentDeleteButton = event.target.closest("[data-messages-segment-delete]"); + const segmentPlayButton = event.target.closest("[data-messages-segment-play]"); + const segmentRow = event.target.closest("[data-messages-segment-row]"); + const row = event.target.closest("[data-messages-row]"); - if (messageAddButton) { - clearValidation(); - state.editingMessageKey = NEW_ROW_KEY; - state.editingSegmentKey = ""; - render(); - setText(elements.log, "Ready to add a message row."); - return; - } - if (playButton) { - state.selectedMessageKey = playButton.dataset.messagesPlay; + if (togglePartsButton) { + const key = togglePartsButton.dataset.messagesToggleParts; + state.selectedMessageKey = state.selectedMessageKey === key ? "" : key; state.selectedSegmentKey = ""; state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; + clearValidation(); render(); - playMessage(playButton.dataset.messagesPlay); + setText(elements.log, state.selectedMessageKey ? "Sentences expanded." : "Sentences collapsed."); return; } if (editButton) { - clearValidation(); - state.editingMessageKey = editButton.dataset.messagesEdit; - state.selectedMessageKey = editButton.dataset.messagesEdit; - state.selectedSegmentKey = ""; - render(); - setText(elements.log, "Message row opened inline."); + beginEditMessage(editButton.dataset.messagesEdit); return; } if (commitButton) { @@ -1033,39 +1292,27 @@ elements.table?.addEventListener("click", async (event) => { return; } if (cancelButton) { - state.editingMessageKey = ""; - clearValidation(); - render(); - setText(elements.log, "Message row edit canceled."); + cancelMessageEdit(); return; } - if (disableButton) { - await disableMessage(disableButton.dataset.messagesDisable); + if (archiveButton) { + await archiveMessageRecord(archiveButton.dataset.messagesArchive); return; } - if (segmentAddButton) { - clearValidation(); - state.editingSegmentKey = NEW_ROW_KEY; - render(); - setText(elements.log, "Ready to add a message part."); + if (deleteButton) { + await deleteMessageRecord(deleteButton.dataset.messagesDelete); 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); + if (playButton) { + playMessage(playButton.dataset.messagesPlay); + return; + } + if (segmentAddButton) { + beginAddSegment(segmentAddButton.dataset.messagesSegmentAdd); return; } if (segmentEditButton) { - clearValidation(); - state.editingSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; - state.selectedSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; - render(); - setText(elements.log, "Message part opened inline."); + beginEditSegment(segmentEditButton.dataset.messagesSegmentEdit); return; } if (segmentCommitButton) { @@ -1073,47 +1320,51 @@ elements.table?.addEventListener("click", async (event) => { return; } if (segmentCancelButton) { - state.editingSegmentKey = ""; - clearValidation(); - render(); - setText(elements.log, "Message part edit canceled."); + cancelSegmentEdit(); return; } - if (segmentDisableButton) { - await disableSegment(segmentDisableButton.dataset.messagesSegmentDisable); + if (segmentArchiveButton) { + await archiveSegmentRecord(segmentArchiveButton.dataset.messagesSegmentArchive); return; } - if (moveUpButton || moveDownButton) { - const key = moveUpButton?.dataset.messagesSegmentMoveUp || moveDownButton?.dataset.messagesSegmentMoveDown; - state.selectedSegmentKey = key; - await moveSegment(key, moveUpButton ? -1 : 1); + if (segmentDeleteButton) { + await deleteSegmentRecord(segmentDeleteButton.dataset.messagesSegmentDelete); return; } - if (segmentRow) { - state.selectedSegmentKey = segmentRow.dataset.messagesSegmentRow; - render(); + if (segmentPlayButton) { + playSentence(segmentPlayButton.dataset.messagesSegmentPlay); return; } - if ((messageNameCell || row) && row) { + if (segmentRow && !event.target.closest("button, input, select, textarea")) { + const segment = state.segments.find((candidate) => candidate.key === segmentRow.dataset.messagesSegmentRow); + if (segment) { + state.selectedMessageKey = segment.messageKey; + state.selectedSegmentKey = segment.key; + render(); + } + return; + } + if (row && !event.target.closest("button, input, select, textarea")) { state.selectedMessageKey = state.selectedMessageKey === row.dataset.messagesRow ? "" : row.dataset.messagesRow; state.selectedSegmentKey = ""; state.editingSegmentKey = ""; + state.editingSegmentMessageKey = ""; render(); } }); elements.table?.addEventListener("keydown", (event) => { - const messageNameCell = event.target.closest("[data-messages-name-cell]"); - if (!messageNameCell || !["Enter", " "].includes(event.key)) { + const row = event.target.closest("[data-messages-row]"); + if (!row || !["Enter", " "].includes(event.key)) { return; } event.preventDefault(); - messageNameCell.click(); + state.selectedMessageKey = state.selectedMessageKey === row.dataset.messagesRow ? "" : row.dataset.messagesRow; + render(); }); try { await loadAll(); -} catch (error) { - setText(elements.log, error instanceof Error ? error.message : String(error || "Messages failed to load.")); - showValidation(["Message Studio could not load from the Local API. Start the Local API server and reload this tool."]); +} catch { + showCreatorSafeFailure("Message Studio could not load messages. Start the Local API and reload this tool."); } diff --git a/toolbox/text-to-speech/index.html b/toolbox/text-to-speech/index.html index 9f509977a..35f232048 100644 --- a/toolbox/text-to-speech/index.html +++ b/toolbox/text-to-speech/index.html @@ -18,7 +18,7 @@
Project Workspace / Audio

Text To Speech

-

Create reusable speech profiles, tune emotion settings, and preview spoken game text through the shared engine audio Text To Speech module.

+

Create reusable speech profiles, tune emotion values, and preview spoken game text through the shared engine audio Text To Speech module.

@@ -42,7 +42,7 @@

Studio Setup

Emotion Ownership
-

Emotion Settings own pitch, rate, volume, and preset values for the selected profile.

+

Emotions own pitch, rate, volume, and preset values for the selected profile.

Message Studio selects a TTS Profile and Emotion for playback.

@@ -53,7 +53,7 @@

Studio Setup

Characters 0
TTS Profiles 0
-
Emotion Settings 0
+
Emotions 0
Voices 0
Engine Checking
@@ -68,9 +68,9 @@

TTS Profiles

Profile + Gender Voice Language - Gender Age Filter Emotion Count Status diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js index b8b2afcce..d98085336 100644 --- a/toolbox/text-to-speech/text2speech.js +++ b/toolbox/text-to-speech/text2speech.js @@ -88,7 +88,8 @@ const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ Object.freeze({ label: "Calm", value: "calm" }), Object.freeze({ label: "Urgent", value: "urgent" }), Object.freeze({ label: "Whisper", value: "whisper" }), - Object.freeze({ label: "Excited", value: "excited" }) + Object.freeze({ label: "Excited", value: "excited" }), + Object.freeze({ label: "Robot", value: "robot" }) ]); function boundedNumber(value, { fallback, max, min, value: defaultValue }) { @@ -136,7 +137,7 @@ function createTtsMessage({ function createEmotionProfile({ key = "neutral", name = "Neutral", intensity = 0.5 } = {}) { const numericIntensity = Number(intensity); const safeIntensity = Number.isNaN(numericIntensity) ? 0.5 : Math.min(1, Math.max(0, numericIntensity)); - return { key: String(key), name: String(name), intensity: safeIntensity, owner: TTS_OWNERSHIP.DESIGN }; + return { key: String(key), name: String(name), intensity: safeIntensity, owner: TTS_OWNERSHIP.AUDIO }; } function createVoiceProfile({ key = "browser-speech", name = "Browser Speech", providerKey = "browser-speech", voiceId = "" } = {}) { @@ -145,7 +146,7 @@ function createVoiceProfile({ key = "browser-speech", name = "Browser Speech", p name: String(name), providerKey: String(providerKey), voiceId: String(voiceId), - owner: TTS_OWNERSHIP.DESIGN, + owner: TTS_OWNERSHIP.AUDIO, generatedAudioOwner: TTS_OWNERSHIP.AUDIO }; } @@ -279,25 +280,62 @@ function createDefaultTextToSpeechProfiles(voiceOptions = []) { ]; } +function createMessageStudioDefaultTtsProfiles(voiceOptions = []) { + const [balancedProfile, manProfile, womanProfile] = createDefaultTextToSpeechProfiles(voiceOptions); + const withStudioEmotions = (profile, emotions) => createTextToSpeechProfile({ + active: profile.active, + age: profile.age, + emotions, + gender: profile.gender, + id: profile.id, + language: profile.language, + messageStudioUsageCount: profile.messageStudioUsageCount, + name: profile.name, + voice: profile.voice, + voiceName: profile.voiceName + }); + + return [ + withStudioEmotions(balancedProfile, [ + createTextToSpeechProfileEmotion({ emotion: "calm", messagePartsUsageCount: 1 }), + createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), + ]), + withStudioEmotions(manProfile, [ + createTextToSpeechProfileEmotion({ emotion: "neutral" }), + createTextToSpeechProfileEmotion({ emotion: "calm" }), + createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), + ]), + withStudioEmotions(womanProfile, [ + createTextToSpeechProfileEmotion({ emotion: "whisper", pitch: 0.95, rate: 0.9, volume: 0.55 }), + createTextToSpeechProfileEmotion({ emotion: "robot", pitch: 0.82, rate: 0.92, volume: 0.9 }), + ]) + ]; +} + function createMessageStudioTtsProfileOptions(profiles = []) { return profiles .filter((profile) => profile?.active !== false) .map((profile) => ({ active: true, + age: profile.age, + ageFilter: profile.age, emotionSettings: Array.isArray(profile.emotions) ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ emotion: emotion.emotion, emotionLabel: emotion.emotionLabel, + key: emotion.id, pitch: emotion.pitch, rate: emotion.rate, ssmlLikePreset: emotion.ssmlLikePreset, volume: emotion.volume })) : [], + gender: profile.gender, key: profile.id, language: profile.language, name: profile.name, providerKey: profile.providerKey || "browser-speech", + voice: profile.voice, voiceName: profile.voiceName || profile.voice || "" })); } @@ -479,7 +517,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech const input = document.createElement("input"); const range = TEXT_TO_SPEECH_RANGE_DEFAULTS[kind] || TEXT_TO_SPEECH_RANGE_DEFAULTS.rate; input.dataset[dataName] = ""; - input.type = "number"; + input.type = "range"; input.min = String(range.min); input.max = String(range.max); input.step = String(range.step); @@ -516,10 +554,14 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech return select; } - function voiceSelectOptions() { - return state.voiceOptions.length + function voiceSelectOptions(currentValue = "") { + const options = state.voiceOptions.length ? state.voiceOptions.map((option) => ({ label: option.label, value: option.value })) : [{ label: "No browser voices available", value: "" }]; + if (currentValue && !options.some((option) => String(option.value) === String(currentValue))) { + return [{ label: profileVoiceName({ voice: currentValue, voiceName: currentValue }), value: currentValue }, ...options]; + } + return options; } function languageSelectOptions() { @@ -558,7 +600,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech nameCell.setAttribute("role", "button"); nameCell.tabIndex = 0; nameCell.setAttribute("aria-expanded", String(state.selectedProfileId === profile.id)); - nameCell.title = "Open Emotion Settings"; + nameCell.title = "Open emotions"; nameCell.textContent = `${state.selectedProfileId === profile.id ? "v" : ">"} ${profile.name}`; const deleteButton = createButton("Delete", "ttsDeleteProfile", profile.id); if (profileInUseByMessageStudio(profile)) { @@ -571,9 +613,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech ); row.append( nameCell, + createCell(labelForOption(TTS_PROFILE_GENDER_OPTIONS, profile.gender, "Neutral")), createCell(profileVoiceName(profile)), createCell(profile.language), - createCell(labelForOption(TTS_PROFILE_GENDER_OPTIONS, profile.gender, "Neutral")), createCell(labelForOption(TEXT_TO_SPEECH_AGE_FILTER_OPTIONS, profile.age, "Any")), createCell(String(profile.emotions.length)), createCell(profile.active ? "Active" : "Inactive"), @@ -613,12 +655,12 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech const nameCell = document.createElement("td"); nameCell.append(createTextInput(profile?.name || "", "ttsProfileName")); + const genderCell = document.createElement("td"); + genderCell.append(createEditorSelect(profile?.gender || "neutral", "ttsProfileGender", TTS_PROFILE_GENDER_OPTIONS)); const voiceCell = document.createElement("td"); - voiceCell.append(createEditorSelect(profile?.voice || "", "ttsProfileVoice", voiceSelectOptions(), "Select voice")); + voiceCell.append(createEditorSelect(profile?.voice || "", "ttsProfileVoice", voiceSelectOptions(profile?.voice || ""), "Select voice")); const languageCell = document.createElement("td"); languageCell.append(createEditorSelect(profile?.language || TEXT_TO_SPEECH_DEFAULTS.language, "ttsProfileLanguage", languageSelectOptions())); - const genderCell = document.createElement("td"); - genderCell.append(createEditorSelect(profile?.gender || "neutral", "ttsProfileGender", TTS_PROFILE_GENDER_OPTIONS)); const ageCell = document.createElement("td"); ageCell.append(createEditorSelect(profile?.age || TEXT_TO_SPEECH_DEFAULTS.voiceAge, "ttsProfileAge", TEXT_TO_SPEECH_AGE_FILTER_OPTIONS)); const emotionCountCell = createCell(profile ? String(profile.emotions.length) : "1"); @@ -630,7 +672,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech createButton("Cancel", "ttsCancelProfile", key), )); - row.append(nameCell, voiceCell, languageCell, genderCell, ageCell, emotionCountCell, statusCell, actionsCell); + row.append(nameCell, genderCell, voiceCell, languageCell, ageCell, emotionCountCell, statusCell, actionsCell); return row; } @@ -649,21 +691,15 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech const profile = state.profiles.find((candidate) => candidate.id === profileId); const wrapper = document.createElement("div"); wrapper.className = "content-stack"; - const context = document.createElement("div"); - context.className = "kicker"; - context.textContent = "TTS Profile / Emotion Settings"; - const heading = document.createElement("h3"); - heading.textContent = "Emotion Settings"; - wrapper.append(context, heading); const tableWrapper = document.createElement("div"); tableWrapper.className = "table-wrapper"; const table = document.createElement("table"); table.className = "data-table"; - table.setAttribute("aria-label", "Emotion Settings"); + table.setAttribute("aria-label", "TTS Profile Emotions"); const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); - ["Emotion", "Pitch", "Rate", "Volume", "Delivery Preset", "Status", "Actions"].forEach((label) => { + ["Emotion", "Pitch", "Rate", "Volume", "Actions"].forEach((label) => { const header = document.createElement("th"); header.scope = "col"; header.textContent = label; @@ -674,7 +710,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech tbody.dataset.ttsEmotionTable = profileId; if (!profile?.emotions.length && state.editingEmotionId !== NEW_ROW_KEY) { - tbody.append(tableMessage(7, "No emotion settings for this profile.")); + tbody.append(tableMessage(5, "No emotions for this profile.")); } profile?.emotions.forEach((emotion) => { @@ -687,11 +723,12 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech const deleteButton = createButton("Delete", "ttsDeleteEmotion", emotion.id); if (emotionInUseByMessageParts(emotion)) { deleteButton.disabled = true; - deleteButton.title = "Delete disabled: emotion is in use by Message Parts."; + deleteButton.title = "Delete disabled: emotion is in use by sentences."; } const actions = createActionGroup( createButton("Edit Emotion", "ttsEditEmotion", emotion.id), deleteButton, + createButton("Play", "ttsPlayEmotion", emotion.id), ); const actionsCell = document.createElement("td"); actionsCell.append(actions); @@ -700,8 +737,6 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech createCell(String(emotion.pitch)), createCell(String(emotion.rate)), createCell(String(emotion.volume)), - createCell(labelForOption(TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS, emotion.ssmlLikePreset, "Normal")), - createCell(emotion.active ? "Active" : "Inactive"), actionsCell, ); tbody.append(row); @@ -721,7 +756,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech } function createEmotionAddControlRow(profileId) { - const row = tableActionRow(7, createButton("Add Emotion", "ttsAddEmotion", profileId)); + const row = tableActionRow(5, createButton("Add Emotion", "ttsAddEmotion", profileId)); row.dataset.ttsEmotionAddControlRow = profileId; return row; } @@ -738,16 +773,12 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech rateCell.append(createNumberInput(emotion?.rate ?? 1, "ttsEmotionRate", "rate")); const volumeCell = document.createElement("td"); volumeCell.append(createNumberInput(emotion?.volume ?? 1, "ttsEmotionVolume", "volume")); - const presetCell = document.createElement("td"); - presetCell.append(createEditorSelect(emotion?.ssmlLikePreset || "normal", "ttsEmotionSsmlPreset", TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS)); - const statusCell = document.createElement("td"); - statusCell.append(createCheckbox(emotion?.active !== false, "ttsEmotionActive")); const actionsCell = document.createElement("td"); actionsCell.append(createActionGroup( createButton("Save", "ttsCommitEmotion", key), createButton("Cancel", "ttsCancelEmotion", key), )); - row.append(emotionCell, pitchCell, rateCell, volumeCell, presetCell, statusCell, actionsCell); + row.append(emotionCell, pitchCell, rateCell, volumeCell, actionsCell); return row; } @@ -788,20 +819,21 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech function emotionValues(key) { const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); + const existing = selectedProfile()?.emotions.find((emotion) => emotion.id === key) || null; return createTextToSpeechProfileEmotion({ - active: editorChecked(row, "[data-tts-emotion-active]"), + active: existing?.active !== false, emotion: editorValue(row, "[data-tts-emotion-name]"), id: key === NEW_ROW_KEY ? "" : key, pitch: editorValue(row, "[data-tts-emotion-pitch]"), rate: editorValue(row, "[data-tts-emotion-rate]"), - ssmlLikePreset: editorValue(row, "[data-tts-emotion-ssml-preset]"), + ssmlLikePreset: existing?.ssmlLikePreset || "normal", volume: editorValue(row, "[data-tts-emotion-volume]"), }); } function validateEmotion(emotion, existingId = "") { const errors = []; - if (!state.selectedProfileId) errors.push("Select a TTS Profile before adding Emotion Settings."); + if (!state.selectedProfileId) errors.push("Select a TTS Profile before adding an emotion."); if (!emotion.emotion) errors.push("Emotion is required."); const profile = selectedProfile(); if (profile?.emotions.some((candidate) => candidate.id !== existingId && candidate.emotion === emotion.emotion)) { @@ -811,6 +843,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech } function addProfile() { + if (engine.isSupported()) { + refreshVoices(); + } state.editingProfileId = NEW_ROW_KEY; state.editingEmotionId = ""; state.selectedProfileId = ""; @@ -864,14 +899,14 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech state.editingProfileId = ""; state.editingEmotionId = NEW_ROW_KEY; renderProfileRows(); - writeStatus("Ready to add an emotion setting."); + writeStatus("Ready to add an emotion."); } function commitEmotion(key) { const emotion = emotionValues(key); const errors = validateEmotion(emotion, key === NEW_ROW_KEY ? "" : key); if (errors.length) { - writeStatus(`Emotion setting save blocked: ${errors.join(" ")}`, "FAIL"); + writeStatus(`Emotion save blocked: ${errors.join(" ")}`, "FAIL"); return; } const profile = selectedProfile(); @@ -890,7 +925,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech state.selectedEmotionId = emotion.id; renderProfileRows(); refreshActionState(); - writeStatus(`Saved emotion setting: ${emotion.emotionLabel}.`); + writeStatus(`Saved emotion: ${emotion.emotionLabel}.`); } function deleteEmotion(key) { @@ -898,14 +933,14 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech const emotion = profile?.emotions.find((candidate) => candidate.id === key); if (!profile || !emotion) return; if (emotionInUseByMessageParts(emotion)) { - writeStatus(`Delete emotion disabled: ${emotion.emotionLabel} is in use by Message Parts.`, "FAIL"); + writeStatus(`Delete emotion disabled: ${emotion.emotionLabel} is in use by sentences.`, "FAIL"); return; } profile.emotions = profile.emotions.filter((candidate) => candidate.id !== key); if (state.selectedEmotionId === key) state.selectedEmotionId = previewEmotion(profile)?.id || ""; renderProfileRows(); refreshActionState(); - writeStatus(`Deleted emotion setting: ${emotion.emotionLabel}.`); + writeStatus(`Deleted emotion: ${emotion.emotionLabel}.`); } function selectProfile(profileId) { @@ -916,7 +951,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech renderProfileRows(); refreshActionState(); if (state.selectedProfileId) { - writeStatus(`Opened Emotion Settings for ${selectedProfile()?.name}.`); + writeStatus(`Opened emotions for ${selectedProfile()?.name}.`); } } @@ -975,15 +1010,18 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech return url.href; } - function speak() { - const profile = previewProfile(); - const emotion = previewEmotion(profile); + function speakEmotion(profile, emotion) { + if (!engine.isSupported()) { + writeStatus("SpeechSynthesis is unavailable in this browser. Use a browser with Web Speech API support.", "FAIL"); + refreshActionState(); + return; + } if (!profile) { writeStatus(`${TEXT_TO_SPEECH_DISPLAY_NAME} Speak blocked: add or select a TTS Profile first.`, "FAIL"); return; } if (!emotion) { - writeStatus(`${TEXT_TO_SPEECH_DISPLAY_NAME} Speak blocked: add or select an Emotion Setting first.`, "FAIL"); + writeStatus(`${TEXT_TO_SPEECH_DISPLAY_NAME} Speak blocked: add or select an emotion first.`, "FAIL"); return; } const request = createSpeechPreviewRequest({ @@ -1001,14 +1039,14 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech return; } const result = engine.speak({ - language: profile.language, + language: request.language, pitch: request.pitch, rate: request.rate, speechItemId: `${profile.id}:${emotion.id}:preview`, speechItemName: previewSpeechLabel(profile, emotion), ssmlLikePreset: emotion.ssmlLikePreset, text: request.text, - voice: profile.voice, + voice: request.voice, voiceAge: profile.age, volume: request.volume, }); @@ -1022,6 +1060,11 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech refreshActionState(); } + function speak() { + const profile = previewProfile(); + speakEmotion(profile, previewEmotion(profile)); + } + function pause() { const result = engine.pause(); if (!result.ok) { @@ -1057,6 +1100,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech const commitEmotionButton = event.target.closest("[data-tts-commit-emotion]"); const cancelEmotionButton = event.target.closest("[data-tts-cancel-emotion]"); const deleteEmotionButton = event.target.closest("[data-tts-delete-emotion]"); + const playEmotionButton = event.target.closest("[data-tts-play-emotion]"); const deleteProfileButton = event.target.closest("[data-tts-delete-profile]"); const emotionRow = event.target.closest("[data-tts-emotion-row]"); const editEmotionButton = event.target.closest("[data-tts-edit-emotion]"); @@ -1080,6 +1124,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech return; } if (editProfileButton) { + if (engine.isSupported()) { + refreshVoices(); + } state.editingProfileId = editProfileButton.dataset.ttsEditProfile; state.selectedProfileId = editProfileButton.dataset.ttsEditProfile; state.selectedEmotionId = previewEmotion(selectedProfile())?.id || ""; @@ -1103,24 +1150,31 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech if (cancelEmotionButton) { state.editingEmotionId = ""; renderProfileRows(); - writeStatus("Emotion setting edit canceled."); + writeStatus("Emotion edit canceled."); return; } if (editEmotionButton) { state.editingEmotionId = editEmotionButton.dataset.ttsEditEmotion; state.selectedEmotionId = editEmotionButton.dataset.ttsEditEmotion; renderProfileRows(); - writeStatus("Emotion setting opened inline."); + writeStatus("Emotion opened inline."); return; } if (deleteEmotionButton) { deleteEmotion(deleteEmotionButton.dataset.ttsDeleteEmotion); return; } + if (playEmotionButton) { + const profile = selectedProfile(); + const emotion = profile?.emotions.find((candidate) => candidate.id === playEmotionButton.dataset.ttsPlayEmotion) || null; + state.selectedEmotionId = playEmotionButton.dataset.ttsPlayEmotion; + speakEmotion(profile, emotion); + return; + } if (emotionRow) { state.selectedEmotionId = emotionRow.dataset.ttsEmotionRow; refreshActionState(); - writeStatus(`Selected Emotion Setting: ${previewEmotion()?.emotionLabel || "Unknown"}.`); + writeStatus(`Selected emotion: ${previewEmotion()?.emotionLabel || "Unknown"}.`); return; } if (profileNameCell) { @@ -1189,7 +1243,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech }; } -if (typeof document !== "undefined") { +if (typeof document !== "undefined" && document.querySelector("[data-tts-profile-table]")) { initializeTextToSpeechTool(document); } @@ -1201,6 +1255,7 @@ export { TTS_PROVIDER_ADAPTER_PLAN, createEmotionProfile, createDefaultTextToSpeechProfiles, + createMessageStudioDefaultTtsProfiles, createMessageStudioTtsProfileOptions, createSpeechPreviewRequest, createTextToSpeechProfile, From c453286782bb90357ddb6ac9b5922e41aa1bff0d Mon Sep 17 00:00:00 2001 From: Team Bravo Date: Tue, 23 Jun 2026 12:20:21 -0400 Subject: [PATCH 2/4] BRAVO EOD report 2026-06-23 --- .../dev/reports/BRAVO_EOD_2026-06-23.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs_build/dev/reports/BRAVO_EOD_2026-06-23.md diff --git a/docs_build/dev/reports/BRAVO_EOD_2026-06-23.md b/docs_build/dev/reports/BRAVO_EOD_2026-06-23.md new file mode 100644 index 000000000..fba8d2b91 --- /dev/null +++ b/docs_build/dev/reports/BRAVO_EOD_2026-06-23.md @@ -0,0 +1,53 @@ +# BRAVO EOD Report - 2026-06-23 + +## Branch Status +- Current Bravo branch: `team/BRAVO/messages` +- Latest implementation commit hash: `7415046cd65c53b24ba0b68aaf38fcf4b4c72517` +- Latest implementation commit subject: `PR_26174_BRAVO_messages_tts_stack` +- Commits relative to `origin/main` at implementation commit: ahead `1`, behind `7` +- Merge status: Not merged to `main` +- Branch kept active: Yes +- Local unstaged non-Bravo change intentionally not committed: `.gitignore` adds `docs_build/dev/ProjectInstructions.zip` + +## Git Status Summary +- Bravo implementation stack committed locally. +- EOD report generated under `docs_build/dev/reports/`. +- Push to GitHub was attempted and blocked by non-interactive authentication. + +## Open Bravo PR List +- No open Bravo PRs were found in `ToolboxAid/HTML-JavaScript-Gaming` through the GitHub connector search. +- PR creation was not completed because the Bravo branch could not be pushed to GitHub. + +## Validation PASS/FAIL Summary +- PASS: `node tests/tools/Text2SpeechShell.test.mjs` +- PASS: `node tests/tools/MessagesPlaybackSource.test.mjs` +- PASS: `node tests/dev-runtime/MessagesPublishValidation.test.mjs` +- PASS: `node --test-name-pattern "Messages Local API seeds" tests/dev-runtime/DbSeedIntegrity.test.mjs` +- PASS: `npm run test:playwright:static` +- PASS: `git diff --check` on Bravo source/test/report files +- FAIL/BLOCKED: `npx playwright install chromium` + - Timed out after five minutes. +- FAIL/BLOCKED: Browser Playwright validation + - Local Chromium executable is unavailable. + +## Push / PR Status +- FAIL: `git push -u origin team/BRAVO/messages` + - Initial attempts timed out. + - Trace retry returned GitHub `401 Unauthorized`. + - Git Credential Manager could not prompt in non-interactive execution. +- FAIL: GitHub CLI workflow + - `gh` is not installed in this environment. +- FAIL: PR creation/update + - No remote Bravo branch exists, so the PR stack could not be created or updated. + +## Known Unresolved Issues +- Parent Message Play validation incomplete. +- Child Sentence Play still showing: `Select an available browser voice before preview.` +- Messages not confirmed to use active Text To Speech profile data. +- PR_021 architecture review failed. + +## Required Follow-Up +- Provide non-interactive GitHub credentials or install/authenticate `gh`. +- Re-run `git push -u origin team/BRAVO/messages`. +- Create/update the Bravo PR stack after the remote branch exists. +- Install Playwright Chromium and re-run the browser Messages validation. From 773bf9583ffc46efc6b361b404c606d4b07a35da Mon Sep 17 00:00:00 2001 From: Team Bravo Date: Tue, 23 Jun 2026 12:44:30 -0400 Subject: [PATCH 3/4] PR_26174_BRAVO_022 use active TTS profiles in Messages --- ...022-use-active-tts-profiles-in-messages.md | 51 + .../dev/reports/codex_changed_files.txt | 36 +- docs_build/dev/reports/codex_review.diff | 2722 ++++++++++++----- .../reports/coverage_changed_js_guardrail.txt | 10 +- .../dev/reports/dependency_gating_report.md | 4 +- .../dependency_hydration_reuse_report.md | 4 +- .../reports/execution_graph_reuse_report.md | 4 +- .../dev/reports/failure_fingerprint_report.md | 8 +- .../filesystem_scan_reduction_report.md | 2 +- .../reports/incremental_validation_report.md | 4 +- .../dev/reports/lane_compilation_report.md | 26 +- .../dev/reports/lane_deduplication_report.md | 2 +- .../reports/lane_input_validation_report.md | 2 +- .../lane_manifests/workspace-contract.json | 28 +- .../lane_runtime_optimization_report.md | 4 +- .../dev/reports/lane_snapshot_report.md | 4 +- .../lane_snapshots/workspace-contract.json | 72 +- .../dev/reports/lane_warm_start_report.md | 4 +- .../lane_warm_starts/workspace-contract.json | 42 +- .../monolith_trigger_removal_report.md | 4 +- .../persistent_lane_manifest_report.md | 6 +- .../playwright_discovery_ownership_report.md | 2 +- .../playwright_discovery_scope_report.md | 2 +- .../dev/reports/playwright_structure_audit.md | 2 +- .../reports/playwright_v8_coverage_report.txt | 19 +- .../dev/reports/retry_suppression_report.md | 6 +- .../dev/reports/slow_path_pruning_report.md | 16 +- .../dev/reports/static_validation_report.md | 4 +- .../reports/targeted_file_manifest_report.md | 4 +- .../test_cleanup_performance_report.md | 20 +- .../reports/test_cleanup_routing_report.md | 4 +- .../reports/testing_lane_execution_report.md | 40 +- .../dev/reports/validation_cache_report.md | 58 +- .../reports/zero_browser_preflight_report.md | 2 +- .../messages/messages-postgres-service.mjs | 71 +- tests/dev-runtime/DbSeedIntegrity.test.mjs | 6 +- tests/playwright/tools/MessagesTool.spec.mjs | 159 +- tests/tools/MessagesPlaybackSource.test.mjs | 18 +- tests/tools/Text2SpeechShell.test.mjs | 30 +- toolbox/messages/messages.js | 138 +- toolbox/text-to-speech/text2speech.js | 92 +- toolbox/text-to-speech/tts-profile-store.js | 160 + 42 files changed, 2767 insertions(+), 1125 deletions(-) create mode 100644 docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md create mode 100644 toolbox/text-to-speech/tts-profile-store.js diff --git a/docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md b/docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md new file mode 100644 index 000000000..69904e644 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md @@ -0,0 +1,51 @@ +# PR_26174_BRAVO_022-use-active-tts-profiles-in-messages + +## Branch Validation +PASS - Current branch is `team/BRAVO/messages`; main was not checked out, merged, or targeted. + +## Summary +- Added a shared Text To Speech profile store/contract helper for active saved TTS profiles. +- Text To Speech now persists its own profile/emotion model for sibling tools. +- Messages now loads saved TTS profiles, syncs profile/emotion names to Local API-owned keys, filters Sentence emotions by selected parent profile, and keeps playback off Text To Speech Local Preview validation. +- Removed the dev-runtime import of the browser Text To Speech UI module and removed separate Message Studio default TTS profile builders. + +## Requirement Checklist +PASS - /toolbox/messages/ loads active saved TTS Profiles from /toolbox/text-to-speech/ via `tts-profile-store.js`. +PASS - Parent Message TTS Profile dropdown is populated from saved active TTS profiles. +PASS - Child Sentence Emotion dropdown is populated only from the selected parent profile's saved emotions. +PASS - Parent Play remains in Message row Actions and resolves ordered Sentences through the selected parent profile. +PASS - Child Play remains in Sentence row Actions and resolves only that Sentence through the parent profile. +PASS - Messages playback does not call Text To Speech Local Preview validation and `toolbox/messages/messages.js` contains no `before preview` text. +PASS - Browser UI module imports were removed from `src/dev-runtime/messages/messages-postgres-service.mjs`. +PASS - Separate Message Studio default TTS profile builders were removed. +PASS - No inline styles, inline scripts, style blocks, or inline handlers were added. +PASS - Local API owns authoritative database keys; Messages syncs saved names to server-created TTS/emotion keys. +PASS - Creator-safe guidance is used for Messages TTS profile load failures. + +## Validation Lane Report +PASS - `node --check toolbox/messages/messages.js`. +PASS - `node --check toolbox/text-to-speech/text2speech.js`. +PASS - `node --check toolbox/text-to-speech/tts-profile-store.js`. +PASS - `node --check src/dev-runtime/messages/messages-postgres-service.mjs`. +PASS - `node --test tests/tools/Text2SpeechShell.test.mjs`. +PASS - `node --test tests/tools/MessagesPlaybackSource.test.mjs`. +PASS - `node --test tests/dev-runtime/MessagesPublishValidation.test.mjs`. +PASS - `node --test --test-name-pattern "Messages Local API" tests/dev-runtime/DbSeedIntegrity.test.mjs`. +BLOCKED - `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs tests/playwright/tools/TextToSpeechFunctional.spec.mjs` could not launch because Chromium is not installed at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. +BLOCKED - Fallback `npm run test:workspace-v2` completed static preflight report generation, then hit the same missing Chromium executable. +KNOWN - Full `tests/dev-runtime/DbSeedIntegrity.test.mjs` still has unrelated Local DB snapshot failures in non-Messages cases. + +## Manual Validation Notes +- Browser manual validation was not completed because Playwright Chromium is missing in this environment. +- Added Playwright coverage for creating/editing a TTS Profile in Text To Speech, opening Messages on the same origin, confirming the saved profile appears, confirming Sentence emotions are profile-filtered, and exercising Parent/Child Play. +- Static grep confirmed Messages no longer contains `Select an available browser voice before preview` or `before preview` wording. + +## Branch Status +- Branch: `team/BRAVO/messages` +- Latest commit: `c45328678` +- Ahead of main: 2 +- Behind main: 0 +- Existing unrelated local edit excluded from PR package: `.gitignore`. + +## ZIP +- Expected ZIP: `tmp/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages_delta.zip` diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index 9bb359709..899ecf86c 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,12 +1,42 @@ -docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md +docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md 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/dependency_gating_report.md +docs_build/dev/reports/dependency_hydration_reuse_report.md +docs_build/dev/reports/execution_graph_reuse_report.md +docs_build/dev/reports/failure_fingerprint_report.md +docs_build/dev/reports/filesystem_scan_reduction_report.md +docs_build/dev/reports/incremental_validation_report.md +docs_build/dev/reports/lane_compilation_report.md +docs_build/dev/reports/lane_deduplication_report.md +docs_build/dev/reports/lane_input_validation_report.md +docs_build/dev/reports/lane_manifests/workspace-contract.json +docs_build/dev/reports/lane_runtime_optimization_report.md +docs_build/dev/reports/lane_snapshot_report.md +docs_build/dev/reports/lane_snapshots/workspace-contract.json +docs_build/dev/reports/lane_warm_start_report.md +docs_build/dev/reports/lane_warm_starts/workspace-contract.json +docs_build/dev/reports/monolith_trigger_removal_report.md +docs_build/dev/reports/persistent_lane_manifest_report.md +docs_build/dev/reports/playwright_discovery_ownership_report.md +docs_build/dev/reports/playwright_discovery_scope_report.md +docs_build/dev/reports/playwright_structure_audit.md +docs_build/dev/reports/playwright_v8_coverage_report.txt +docs_build/dev/reports/retry_suppression_report.md +docs_build/dev/reports/slow_path_pruning_report.md +docs_build/dev/reports/static_validation_report.md +docs_build/dev/reports/targeted_file_manifest_report.md +docs_build/dev/reports/test_cleanup_performance_report.md +docs_build/dev/reports/test_cleanup_routing_report.md +docs_build/dev/reports/testing_lane_execution_report.md +docs_build/dev/reports/validation_cache_report.md +docs_build/dev/reports/zero_browser_preflight_report.md src/dev-runtime/messages/messages-postgres-service.mjs tests/dev-runtime/DbSeedIntegrity.test.mjs -tests/dev-runtime/MessagesPublishValidation.test.mjs -tests/playwright/tools/EventsTool.spec.mjs tests/playwright/tools/MessagesTool.spec.mjs tests/tools/MessagesPlaybackSource.test.mjs tests/tools/Text2SpeechShell.test.mjs toolbox/messages/messages.js toolbox/text-to-speech/text2speech.js +toolbox/text-to-speech/tts-profile-store.js diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 59c87d98f..5dc7e958e 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,571 +1,1388 @@ +diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt +index 9bb359709..899ecf86c 100644 +--- a/docs_build/dev/reports/codex_changed_files.txt ++++ b/docs_build/dev/reports/codex_changed_files.txt +@@ -1,12 +1,42 @@ +-docs_build/dev/reports/PR_26174_BRAVO_021-wire-messages-to-tts-profile-contract.md ++docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md + 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/dependency_gating_report.md ++docs_build/dev/reports/dependency_hydration_reuse_report.md ++docs_build/dev/reports/execution_graph_reuse_report.md ++docs_build/dev/reports/failure_fingerprint_report.md ++docs_build/dev/reports/filesystem_scan_reduction_report.md ++docs_build/dev/reports/incremental_validation_report.md ++docs_build/dev/reports/lane_compilation_report.md ++docs_build/dev/reports/lane_deduplication_report.md ++docs_build/dev/reports/lane_input_validation_report.md ++docs_build/dev/reports/lane_manifests/workspace-contract.json ++docs_build/dev/reports/lane_runtime_optimization_report.md ++docs_build/dev/reports/lane_snapshot_report.md ++docs_build/dev/reports/lane_snapshots/workspace-contract.json ++docs_build/dev/reports/lane_warm_start_report.md ++docs_build/dev/reports/lane_warm_starts/workspace-contract.json ++docs_build/dev/reports/monolith_trigger_removal_report.md ++docs_build/dev/reports/persistent_lane_manifest_report.md ++docs_build/dev/reports/playwright_discovery_ownership_report.md ++docs_build/dev/reports/playwright_discovery_scope_report.md ++docs_build/dev/reports/playwright_structure_audit.md ++docs_build/dev/reports/playwright_v8_coverage_report.txt ++docs_build/dev/reports/retry_suppression_report.md ++docs_build/dev/reports/slow_path_pruning_report.md ++docs_build/dev/reports/static_validation_report.md ++docs_build/dev/reports/targeted_file_manifest_report.md ++docs_build/dev/reports/test_cleanup_performance_report.md ++docs_build/dev/reports/test_cleanup_routing_report.md ++docs_build/dev/reports/testing_lane_execution_report.md ++docs_build/dev/reports/validation_cache_report.md ++docs_build/dev/reports/zero_browser_preflight_report.md + src/dev-runtime/messages/messages-postgres-service.mjs + tests/dev-runtime/DbSeedIntegrity.test.mjs +-tests/dev-runtime/MessagesPublishValidation.test.mjs +-tests/playwright/tools/EventsTool.spec.mjs + tests/playwright/tools/MessagesTool.spec.mjs + tests/tools/MessagesPlaybackSource.test.mjs + tests/tools/Text2SpeechShell.test.mjs + toolbox/messages/messages.js + toolbox/text-to-speech/text2speech.js ++toolbox/text-to-speech/tts-profile-store.js +diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt +index 74a29674c..83a4ad849 100644 +--- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt ++++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt +@@ -6,7 +6,13 @@ Missing changed runtime JS files are WARN, not FAIL. + Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. + + Changed runtime JS files considered: +-(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only + + Guardrail warnings: +-(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file missing from coverage; advisory only ++(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file missing from coverage; advisory only ++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only ++(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file missing from coverage; advisory only ++(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: changed runtime JS file missing from coverage; advisory only +diff --git a/docs_build/dev/reports/dependency_gating_report.md b/docs_build/dev/reports/dependency_gating_report.md +index 319848da9..10f49ad99 100644 +--- a/docs_build/dev/reports/dependency_gating_report.md ++++ b/docs_build/dev/reports/dependency_gating_report.md +@@ -1,6 +1,6 @@ + # Dependency Gating Report + +-Generated: 2026-06-21T00:10:10.215Z ++Generated: 2026-06-23T16:38:48.295Z + Status: PASS + + ## Gate Order +@@ -15,7 +15,7 @@ Status: PASS + | Lane | Selected | Status | Dependencies | Affected Surface | Reason | + | --- | --- | --- | --- | --- | --- | + | workspace-contract | Yes | PASS | none | Root tools future-state navigation and Tool Template V2 contract | Lane has no lane dependencies and is eligible after preflight and compilation pass. | +-| game-workspace | No | SKIP | none | Game Workspace mock repository, Game Workspace UI, and Toolbox Progress/Build Path game-state bridge | Lane was not selected, so dependency-gated runtime scheduling skipped it. | ++| game-hub | No | SKIP | none | Game Hub mock repository, Game Hub UI, and Toolbox Progress/Build Path game-state bridge | Lane was not selected, so dependency-gated runtime scheduling skipped it. | + | game-design | No | SKIP | none | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | Lane was not selected, so dependency-gated runtime scheduling skipped it. | + | game-configuration | No | SKIP | none | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | Lane was not selected, so dependency-gated runtime scheduling skipped it. | + | asset-tool | No | SKIP | none | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | Lane was not selected, so dependency-gated runtime scheduling skipped it. | +diff --git a/docs_build/dev/reports/dependency_hydration_reuse_report.md b/docs_build/dev/reports/dependency_hydration_reuse_report.md +index 0f8c874e3..4546b7441 100644 +--- a/docs_build/dev/reports/dependency_hydration_reuse_report.md ++++ b/docs_build/dev/reports/dependency_hydration_reuse_report.md +@@ -1,6 +1,6 @@ + # Dependency Hydration Reuse Report + +-Generated: 2026-06-21T00:10:10.216Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + + ## Summary +@@ -16,7 +16,7 @@ Prevented fixture ownership traversal: 0 + + | Lane | Status | Helpers | Fixtures | Imports | Dependency Hydration Hash | Reason | + | --- | --- | --- | --- | --- | --- | --- | +-| workspace-contract | INVALIDATED | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | 355ba7a85dbb3cdb | Dependency hydration was refreshed after warm-start invalidation. | ++| workspace-contract | INVALIDATED | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | e9956a75c3585e86 | Dependency hydration was refreshed after warm-start invalidation. | + + ## Safeguards + +diff --git a/docs_build/dev/reports/execution_graph_reuse_report.md b/docs_build/dev/reports/execution_graph_reuse_report.md +index ddbdf90a1..b875bdac2 100644 +--- a/docs_build/dev/reports/execution_graph_reuse_report.md ++++ b/docs_build/dev/reports/execution_graph_reuse_report.md +@@ -1,6 +1,6 @@ + # Execution Graph Reuse Report + +-Generated: 2026-06-21T00:10:10.216Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + + ## Summary +@@ -16,7 +16,7 @@ Prevented targeted scheduling work: 0 + + | Lane | Status | Snapshot Status | Execution Graph Hash | Reason | + | --- | --- | --- | --- | --- | +-| workspace-contract | INVALIDATED | INVALIDATED | 51dc9d019b71a923 | Lane snapshot is part of the selected targeted execution graph. | ++| workspace-contract | INVALIDATED | INVALIDATED | 98ec1cea6aaaef57 | Lane snapshot is part of the selected targeted execution graph. | + + ## Safeguards + +diff --git a/docs_build/dev/reports/failure_fingerprint_report.md b/docs_build/dev/reports/failure_fingerprint_report.md +index c9822fe4a..a1d3f0de6 100644 +--- a/docs_build/dev/reports/failure_fingerprint_report.md ++++ b/docs_build/dev/reports/failure_fingerprint_report.md +@@ -1,12 +1,12 @@ + # Failure Fingerprint Report + +-Generated: 2026-06-21T00:11:18.106Z +-Status: PASS ++Generated: 2026-06-23T16:38:57.091Z ++Status: WARN + + ## Summary + + Deterministic setup failures: 0 +-Runtime failures: 0 ++Runtime failures: 1 + Flaky/transient failures: 0 + Infrastructure failures: 0 + +@@ -14,7 +14,7 @@ Infrastructure failures: 0 + + | Fingerprint | Category | Rule | Lane | Source | Retry Allowed | Diagnostic | + | --- | --- | --- | --- | --- | --- | --- | +-| none | none | none | none | none | No | No failures observed during deterministic classification. | ++| fc8ad6ba552baa70 | runtime failure | runtime-failure | workspace-contract | runtime command | Yes | workspace-contract command failed: "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | + + ## Known Deterministic Fingerprint Rules + +diff --git a/docs_build/dev/reports/filesystem_scan_reduction_report.md b/docs_build/dev/reports/filesystem_scan_reduction_report.md +index 3fc7accce..062fbbe26 100644 +--- a/docs_build/dev/reports/filesystem_scan_reduction_report.md ++++ b/docs_build/dev/reports/filesystem_scan_reduction_report.md +@@ -1,6 +1,6 @@ + # Filesystem Scan Reduction Report + +-Generated: 2026-06-21T00:10:10.181Z ++Generated: 2026-06-23T16:38:48.280Z + Status: PASS + + ## Scan Enforcement +diff --git a/docs_build/dev/reports/incremental_validation_report.md b/docs_build/dev/reports/incremental_validation_report.md +index d4aa9a02b..43aa18265 100644 +--- a/docs_build/dev/reports/incremental_validation_report.md ++++ b/docs_build/dev/reports/incremental_validation_report.md +@@ -1,6 +1,6 @@ + # Incremental Validation Report + +-Generated: 2026-06-21T00:10:10.218Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + + ## Reuse Summary +@@ -18,7 +18,7 @@ Prevented fixture resolution passes: 0 + + | Lane | Decision | Invalidated By | Runtime Savings Observation | + | --- | --- | --- | --- | +-| workspace-contract | INVALIDATED | Persistent manifest input hash changed for workspace-contract.; Persistent manifest hash changed for workspace-contract. | Manifest was regenerated or skipped; no reuse savings for this lane. | ++| workspace-contract | INVALIDATED | Persistent manifest lane definition hash changed for workspace-contract. | Manifest was regenerated or skipped; no reuse savings for this lane. | + + ## Invalidation Rules + +diff --git a/docs_build/dev/reports/lane_compilation_report.md b/docs_build/dev/reports/lane_compilation_report.md +index 1b65b72a4..c0a7442d4 100644 +--- a/docs_build/dev/reports/lane_compilation_report.md ++++ b/docs_build/dev/reports/lane_compilation_report.md +@@ -1,26 +1,26 @@ + # Lane Compilation Report + +-Generated: 2026-06-21T00:10:10.215Z ++Generated: 2026-06-23T16:38:48.295Z + Status: PASS + + ## Lane Graph + + | Lane | Status | Affected Surface | Targets | Commands | Reason | + | --- | --- | --- | --- | --- | --- | +-| workspace-contract | PASS | Root tools future-state navigation and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane graph, command shape, targets, fixtures, and ownership compile before runtime. | +-| game-workspace | SKIP | Game Workspace mock repository, Game Workspace UI, and Toolbox Progress/Build Path game-state bridge | tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| game-design | SKIP | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | tests/playwright/tools/GameDesignMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/GameDesignMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| game-configuration | SKIP | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | tests/playwright/tools/GameConfigurationMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/GameConfigurationMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| asset-tool | SKIP | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | tests/playwright/tools/AssetToolMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/AssetToolMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| build-path | SKIP | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | tests/playwright/tools/BuildPathProgressSimplification.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/BuildPathProgressSimplification.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| tools-progress | SKIP | Admin Tools Progress hydration, Toolbox Group view color model, and Game Build Path separation | tests/playwright/tools/ToolsProgressHydration.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolsProgressHydration.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| tool-navigation | SKIP | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | tests/playwright/tools/ToolNavigationPrevNext.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolNavigationPrevNext.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| tool-display-mode | SKIP | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| tool-images | SKIP | Toolbox registry image contract, Toolbox card image rendering, and Tool Display Mode image fallback | tests/playwright/tools/ToolImageRegistry.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolImageRegistry.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +-| tool-runtime | SKIP | Active public toolbox and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| workspace-contract | PASS | Root tools future-state navigation and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane graph, command shape, targets, fixtures, and ownership compile before runtime. | ++| game-hub | SKIP | Game Hub mock repository, Game Hub UI, and Toolbox Progress/Build Path game-state bridge | tests/playwright/tools/GameHubMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/GameHubMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| game-design | SKIP | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | tests/playwright/tools/GameDesignMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/GameDesignMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| game-configuration | SKIP | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | tests/playwright/tools/GameConfigurationMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/GameConfigurationMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| asset-tool | SKIP | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | tests/playwright/tools/AssetToolMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/AssetToolMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| build-path | SKIP | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | tests/playwright/tools/BuildPathProgressSimplification.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/BuildPathProgressSimplification.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| tools-progress | SKIP | Admin Tools Progress hydration, Toolbox Group view color model, and Game Build Path separation | tests/playwright/tools/ToolsProgressHydration.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolsProgressHydration.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| tool-navigation | SKIP | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | tests/playwright/tools/ToolNavigationPrevNext.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolNavigationPrevNext.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| tool-display-mode | SKIP | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| tool-images | SKIP | Toolbox registry image contract, Toolbox card image rendering, and Tool Display Mode image fallback | tests/playwright/tools/ToolImageRegistry.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolImageRegistry.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | ++| tool-runtime | SKIP | Active public toolbox and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | + | game-runtime | SKIP | Deprecated archive/v1-v2/games reference coverage | none | none | Lane was not selected. | + | integration | SKIP | Integration handoff behavior | none | none | Lane was not selected. | +-| engine-src | SKIP | src/ engine and shared runtime capability behavior | tests/core/EngineCoreBoundaryBaseline.test.mjs; tests/core/FrameClock.test.mjs; tests/core/FixedTicker.test.mjs; tests/assets/AssetLoaderSystem.test.mjs; tests/audio/AudioService.test.mjs; tests/input/InputMap.test.mjs; tests/input/KeyboardState.test.mjs; tests/input/MouseState.test.mjs; tests/input/GamepadInputAdapter.test.mjs; tests/input/GamepadHapticsService.test.mjs; tests/render/Renderer.test.mjs | C:\nvm4w\nodejs\node.exe scripts/run-node-test-files.mjs tests/core/EngineCoreBoundaryBaseline.test.mjs tests/core/FrameClock.test.mjs tests/core/FixedTicker.test.mjs tests/assets/AssetLoaderSystem.test.mjs tests/audio/AudioService.test.mjs tests/input/InputMap.test.mjs tests/input/KeyboardState.test.mjs tests/input/MouseState.test.mjs tests/input/GamepadInputAdapter.test.mjs tests/input/GamepadHapticsService.test.mjs tests/render/Renderer.test.mjs | Lane was not selected. | ++| engine-src | SKIP | src/ engine and shared runtime capability behavior | tests/core/EngineCoreBoundaryBaseline.test.mjs; tests/core/FrameClock.test.mjs; tests/core/FixedTicker.test.mjs; tests/assets/AssetLoaderSystem.test.mjs; tests/audio/AudioService.test.mjs; tests/input/InputMap.test.mjs; tests/input/KeyboardState.test.mjs; tests/input/MouseState.test.mjs; tests/input/GamepadInputAdapter.test.mjs; tests/input/GamepadHapticsService.test.mjs; tests/render/Renderer.test.mjs | "C:\\Program Files\\nodejs\\node.exe" scripts/run-node-test-files.mjs tests/core/EngineCoreBoundaryBaseline.test.mjs tests/core/FrameClock.test.mjs tests/core/FixedTicker.test.mjs tests/assets/AssetLoaderSystem.test.mjs tests/audio/AudioService.test.mjs tests/input/InputMap.test.mjs tests/input/KeyboardState.test.mjs tests/input/MouseState.test.mjs tests/input/GamepadInputAdapter.test.mjs tests/input/GamepadHapticsService.test.mjs tests/render/Renderer.test.mjs | Lane was not selected. | + | samples | SKIP | Deprecated archive/v1-v2/samples reference coverage | none | none | Lane was not selected. | + + ## Compilation Failures +diff --git a/docs_build/dev/reports/lane_deduplication_report.md b/docs_build/dev/reports/lane_deduplication_report.md +index ca9adb0a3..112fe485b 100644 +--- a/docs_build/dev/reports/lane_deduplication_report.md ++++ b/docs_build/dev/reports/lane_deduplication_report.md +@@ -1,6 +1,6 @@ + # Lane Deduplication Report + +-Generated: 2026-06-21T00:10:10.215Z ++Generated: 2026-06-23T16:38:48.295Z + Status: PASS + + ## Summary +diff --git a/docs_build/dev/reports/lane_input_validation_report.md b/docs_build/dev/reports/lane_input_validation_report.md +index 0c7546c68..2911bcfe5 100644 +--- a/docs_build/dev/reports/lane_input_validation_report.md ++++ b/docs_build/dev/reports/lane_input_validation_report.md +@@ -1,6 +1,6 @@ + # Lane Input Validation Report + +-Generated: 2026-06-21T00:10:10.218Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + + ## Input Files +diff --git a/docs_build/dev/reports/lane_manifests/workspace-contract.json b/docs_build/dev/reports/lane_manifests/workspace-contract.json +index fb867147c..84148f335 100644 +--- a/docs_build/dev/reports/lane_manifests/workspace-contract.json ++++ b/docs_build/dev/reports/lane_manifests/workspace-contract.json +@@ -1,17 +1,17 @@ + { +- "commandsHash": "43673d11b6eb7f6f", ++ "commandsHash": "e2ef303e88f244be", + "dependencies": [], +- "dependencyGraphHash": "53e56ebae6e84541", ++ "dependencyGraphHash": "4dcb31ccda1bdb5a", + "fileHashes": { + "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", +- "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", +- "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", +- "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", +- "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", +- "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", +- "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", +- "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85", +- "tests/playwright/tools/RootToolsFutureState.spec.mjs": "032c57abb5289a23" ++ "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", ++ "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", ++ "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", ++ "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", ++ "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", ++ "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", ++ "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1", ++ "tests/playwright/tools/RootToolsFutureState.spec.mjs": "fda17eab0bf8a418" + }, + "fixtures": [], + "helpers": [ +@@ -30,15 +30,15 @@ + "tests/helpers/playwrightV8CoverageReporter.mjs", + "tests/helpers/workspaceV2CoverageReporter.mjs" + ], +- "inputHash": "5145d05e2885e902", ++ "inputHash": "61358ffa71dc7104", + "lane": "workspace-contract", +- "laneDefinitionHash": "95059517ac8a6497", +- "manifestHash": "d98d3245976c92f4", ++ "laneDefinitionHash": "97570e267d472bc8", ++ "manifestHash": "fdada39983df06d5", + "ownership": "tools", + "tests": [ + "tests/playwright/tools/RootToolsFutureState.spec.mjs" + ], + "version": 1, +- "generatedAt": "2026-06-21T00:10:08.792Z", ++ "generatedAt": "2026-06-23T16:38:47.630Z", + "source": "generated" + } +diff --git a/docs_build/dev/reports/lane_runtime_optimization_report.md b/docs_build/dev/reports/lane_runtime_optimization_report.md +index a8d52d441..4152520f7 100644 +--- a/docs_build/dev/reports/lane_runtime_optimization_report.md ++++ b/docs_build/dev/reports/lane_runtime_optimization_report.md +@@ -1,6 +1,6 @@ + # Lane Runtime Optimization Report + +-Generated: 2026-06-21T00:10:10.215Z ++Generated: 2026-06-23T16:38:48.295Z + Status: PASS + + ## Runtime Cost Summary +@@ -28,7 +28,7 @@ No zero-browser, compilation, or dependency blockers were found. + + | Lane | Snapshot | Warm Start | Hydration | Baseline Browser Launches | Scheduled Browser Launches | Commands | Reason | + | --- | --- | --- | --- | --- | --- | --- | --- | +-| workspace-contract | INVALIDATED | INVALIDATED | INVALIDATED | 1 | 1 | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | ++| workspace-contract | INVALIDATED | INVALIDATED | INVALIDATED | 1 | 1 | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | + + ## Runtime Savings Observations + +diff --git a/docs_build/dev/reports/lane_snapshot_report.md b/docs_build/dev/reports/lane_snapshot_report.md +index 5037cf0b8..727fddbd0 100644 +--- a/docs_build/dev/reports/lane_snapshot_report.md ++++ b/docs_build/dev/reports/lane_snapshot_report.md +@@ -1,6 +1,6 @@ + # Lane Snapshot Report + +-Generated: 2026-06-21T00:10:10.216Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + Snapshot directory: docs_build/dev/reports/lane_snapshots + +@@ -17,7 +17,7 @@ Prevented manifest traversal: 0 + + | Lane | Status | Snapshot Path | Manifest Hash | Dependency Graph Hash | Helper Graph Hash | Fixture Graph Hash | Runtime Config Hash | Execution Graph Hash | Snapshot Hash | Reason | + | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +-| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_snapshots/workspace-contract.json | d98d3245976c92f4 | 53e56ebae6e84541 | 7d3db838f9f780e0 | 6c4fac7630b0b6f3 | e5ac9cbc103c3984 | 51dc9d019b71a923 | 13ffd9b4d27b4d42 | Lane snapshot executionGraphHash changed for workspace-contract.; Lane snapshot inputHash changed for workspace-contract.; Lane snapshot manifestHash changed for workspace-contract.; Lane snapshot snapshotHash changed for workspace-contract.; Lane snapshot warmStartHash changed for workspace-contract. | ++| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_snapshots/workspace-contract.json | fdada39983df06d5 | 4dcb31ccda1bdb5a | 6cee3b9e8c76ce0d | 6c4fac7630b0b6f3 | 2ba7784af3a4df8d | 98ec1cea6aaaef57 | 54af1dc18e270910 | Lane snapshot commandsHash changed for workspace-contract.; Lane snapshot dependencyGraphHash changed for workspace-contract.; Lane snapshot executionGraphHash changed for workspace-contract.; Lane snapshot helperGraphHash changed for workspace-contract.; Lane snapshot inputHash changed for workspace-contract.; Lane snapshot laneDefinitionHash changed for workspace-contract.; Lane snapshot manifestHash changed for workspace-contract.; Lane snapshot runtimeConfigurationHash changed for workspace-contract.; Lane snapshot snapshotHash changed for workspace-contract.; Lane snapshot warmStartHash changed for workspace-contract. | + + ## Snapshot Validation Findings + +diff --git a/docs_build/dev/reports/lane_snapshots/workspace-contract.json b/docs_build/dev/reports/lane_snapshots/workspace-contract.json +index e0fe4f05f..c2111ff5a 100644 +--- a/docs_build/dev/reports/lane_snapshots/workspace-contract.json ++++ b/docs_build/dev/reports/lane_snapshots/workspace-contract.json +@@ -1,18 +1,18 @@ + { +- "commandsHash": "43673d11b6eb7f6f", ++ "commandsHash": "e2ef303e88f244be", + "dependencyGateStatus": "PASS", + "dependencyGraph": { + "dependencies": [], +- "dependencyGraphHash": "53e56ebae6e84541", ++ "dependencyGraphHash": "4dcb31ccda1bdb5a", + "importHashes": { + "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", +- "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", +- "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", +- "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", +- "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", +- "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", +- "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", +- "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" ++ "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", ++ "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", ++ "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", ++ "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", ++ "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", ++ "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", ++ "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" + }, + "imports": [ + "src/dev-runtime/admin/admin-notes-directory.mjs", +@@ -25,8 +25,8 @@ + "tests/helpers/workspaceV2CoverageReporter.mjs" + ] + }, +- "dependencyGraphHash": "53e56ebae6e84541", +- "executionGraphHash": "51dc9d019b71a923", ++ "dependencyGraphHash": "4dcb31ccda1bdb5a", ++ "executionGraphHash": "98ec1cea6aaaef57", + "fixtureGraph": { + "fixtureHashes": {}, + "fixtures": [] +@@ -34,10 +34,10 @@ + "fixtureGraphHash": "6c4fac7630b0b6f3", + "helperGraph": { + "helperHashes": { +- "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", +- "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", +- "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", +- "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" ++ "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", ++ "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", ++ "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", ++ "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" + }, + "helpers": [ + "tests/helpers/playwrightRepoServer.mjs", +@@ -46,22 +46,22 @@ + "tests/helpers/workspaceV2CoverageReporter.mjs" + ] + }, +- "helperGraphHash": "7d3db838f9f780e0", +- "inputHash": "5145d05e2885e902", ++ "helperGraphHash": "6cee3b9e8c76ce0d", ++ "inputHash": "61358ffa71dc7104", + "lane": "workspace-contract", + "laneCompilationStatus": "PASS", +- "laneDefinitionHash": "95059517ac8a6497", ++ "laneDefinitionHash": "97570e267d472bc8", + "manifest": { + "fileHashes": { + "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", +- "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", +- "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", +- "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", +- "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", +- "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", +- "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", +- "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85", +- "tests/playwright/tools/RootToolsFutureState.spec.mjs": "032c57abb5289a23" ++ "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", ++ "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", ++ "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", ++ "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", ++ "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", ++ "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", ++ "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1", ++ "tests/playwright/tools/RootToolsFutureState.spec.mjs": "fda17eab0bf8a418" + }, + "fixtures": [], + "helpers": [ +@@ -80,42 +80,42 @@ + "tests/helpers/playwrightV8CoverageReporter.mjs", + "tests/helpers/workspaceV2CoverageReporter.mjs" + ], +- "manifestHash": "d98d3245976c92f4", ++ "manifestHash": "fdada39983df06d5", + "manifestPath": "docs_build/dev/reports/lane_manifests/workspace-contract.json", + "source": "generated", + "tests": [ + "tests/playwright/tools/RootToolsFutureState.spec.mjs" + ] + }, +- "manifestHash": "d98d3245976c92f4", ++ "manifestHash": "fdada39983df06d5", + "ownership": "tools", + "runtimeConfiguration": { + "affectedSurface": "Root tools future-state navigation and Tool Template V2 contract", + "commands": [ + { + "args": [ +- "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js", ++ "C:\\Users\\davidq\\Documents\\github\\GameFoundryStudio\\node_modules\\@playwright\\test\\cli.js", + "test", + "tests/playwright/tools/RootToolsFutureState.spec.mjs", + "--project=playwright", + "--workers=1", + "--reporter=list" + ], +- "command": "C:\\nvm4w\\nodejs\\node.exe", ++ "command": "C:\\Program Files\\nodejs\\node.exe", + "targets": [ + "tests/playwright/tools/RootToolsFutureState.spec.mjs" + ], + "type": "playwright" + } + ], +- "commandsHash": "43673d11b6eb7f6f", +- "laneConfigHash": "09eaf9063f7694e3", ++ "commandsHash": "e2ef303e88f244be", ++ "laneConfigHash": "66a7f2d814114595", + "requiresPreflight": true, + "requiresSamplesFlag": false + }, +- "runtimeConfigurationHash": "e5ac9cbc103c3984", +- "snapshotHash": "13ffd9b4d27b4d42", ++ "runtimeConfigurationHash": "2ba7784af3a4df8d", ++ "snapshotHash": "54af1dc18e270910", + "version": 1, +- "warmStartHash": "f32a5831d6b39914", +- "generatedAt": "2026-06-21T00:10:10.207Z" ++ "warmStartHash": "89d30ba62849a51c", ++ "generatedAt": "2026-06-23T16:38:48.293Z" + } +diff --git a/docs_build/dev/reports/lane_warm_start_report.md b/docs_build/dev/reports/lane_warm_start_report.md +index d06a40a33..8ce74ca47 100644 +--- a/docs_build/dev/reports/lane_warm_start_report.md ++++ b/docs_build/dev/reports/lane_warm_start_report.md +@@ -1,6 +1,6 @@ + # Lane Warm-Start Report + +-Generated: 2026-06-21T00:10:10.216Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + Warm-start directory: docs_build/dev/reports/lane_warm_starts + +@@ -17,7 +17,7 @@ Prevented lane graph assembly: 0 + + | Lane | Status | Warm-Start Path | Manifest Hash | Warm-Start Hash | Dependency Hydration Hash | Reason | + | --- | --- | --- | --- | --- | --- | --- | +-| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_warm_starts/workspace-contract.json | d98d3245976c92f4 | f32a5831d6b39914 | 355ba7a85dbb3cdb | Warm-start inputHash changed for workspace-contract.; Warm-start manifestHash changed for workspace-contract.; Warm-start warmStartHash changed for workspace-contract. | ++| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_warm_starts/workspace-contract.json | fdada39983df06d5 | 89d30ba62849a51c | e9956a75c3585e86 | Warm-start commandsHash changed for workspace-contract.; Warm-start dependencyGraphHash changed for workspace-contract.; Warm-start dependencyHydrationHash changed for workspace-contract.; Warm-start inputHash changed for workspace-contract.; Warm-start laneConfigHash changed for workspace-contract.; Warm-start laneDefinitionHash changed for workspace-contract.; Warm-start manifestHash changed for workspace-contract.; Warm-start warmStartHash changed for workspace-contract. | + + ## Fast-Fail Safeguards + +diff --git a/docs_build/dev/reports/lane_warm_starts/workspace-contract.json b/docs_build/dev/reports/lane_warm_starts/workspace-contract.json +index 3c29e890d..88a7c314a 100644 +--- a/docs_build/dev/reports/lane_warm_starts/workspace-contract.json ++++ b/docs_build/dev/reports/lane_warm_starts/workspace-contract.json +@@ -1,15 +1,15 @@ + { +- "commandsHash": "43673d11b6eb7f6f", +- "dependencyGraphHash": "53e56ebae6e84541", ++ "commandsHash": "e2ef303e88f244be", ++ "dependencyGraphHash": "4dcb31ccda1bdb5a", + "dependencyHydration": { +- "dependencyHydrationHash": "355ba7a85dbb3cdb", ++ "dependencyHydrationHash": "e9956a75c3585e86", + "fixtureHashes": {}, + "fixtures": [], + "helperHashes": { +- "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", +- "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", +- "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", +- "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" ++ "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", ++ "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", ++ "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", ++ "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" + }, + "helpers": [ + "tests/helpers/playwrightRepoServer.mjs", +@@ -19,13 +19,13 @@ + ], + "importHashes": { + "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", +- "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", +- "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", +- "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", +- "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", +- "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", +- "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", +- "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" ++ "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", ++ "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", ++ "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", ++ "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", ++ "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", ++ "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", ++ "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" + }, + "imports": [ + "src/dev-runtime/admin/admin-notes-directory.mjs", +@@ -38,16 +38,16 @@ + "tests/helpers/workspaceV2CoverageReporter.mjs" + ] + }, +- "dependencyHydrationHash": "355ba7a85dbb3cdb", +- "inputHash": "5145d05e2885e902", ++ "dependencyHydrationHash": "e9956a75c3585e86", ++ "inputHash": "61358ffa71dc7104", + "lane": "workspace-contract", +- "laneConfigHash": "09eaf9063f7694e3", +- "laneDefinitionHash": "95059517ac8a6497", +- "manifestHash": "d98d3245976c92f4", ++ "laneConfigHash": "66a7f2d814114595", ++ "laneDefinitionHash": "97570e267d472bc8", ++ "manifestHash": "fdada39983df06d5", + "ownership": "tools", +- "warmStartHash": "f32a5831d6b39914", ++ "warmStartHash": "89d30ba62849a51c", + "version": 1, +- "generatedAt": "2026-06-21T00:10:08.802Z", ++ "generatedAt": "2026-06-23T16:38:47.635Z", + "manifestPath": "docs_build/dev/reports/lane_manifests/workspace-contract.json", + "sourceManifest": "generated" + } +diff --git a/docs_build/dev/reports/monolith_trigger_removal_report.md b/docs_build/dev/reports/monolith_trigger_removal_report.md +index 97a55c340..266ecc808 100644 +--- a/docs_build/dev/reports/monolith_trigger_removal_report.md ++++ b/docs_build/dev/reports/monolith_trigger_removal_report.md +@@ -1,6 +1,6 @@ + # Monolith Trigger Removal Report + +-Generated: 2026-06-21T00:11:18.108Z ++Generated: 2026-06-23T16:38:57.092Z + Status: PASS + + ## Removed Broad Execution Triggers +@@ -30,7 +30,7 @@ Status: PASS + No-argument safe mode active for this invocation: No + Scheduled runtime lanes: workspace-contract + Executed lanes: workspace-contract +-Skipped lanes: game-workspace, game-design, game-configuration, asset-tool, build-path, tools-progress, tool-navigation, tool-display-mode, tool-images, tool-runtime, game-runtime, integration, engine-src, samples ++Skipped lanes: game-hub, game-design, game-configuration, asset-tool, build-path, tools-progress, tool-navigation, tool-display-mode, tool-images, tool-runtime, game-runtime, integration, engine-src, samples + Full samples smoke: SKIP - Skipped because changed files do not modify sample JSON or shared sample loader/framework behavior. + Unaffected lane execution blocked: Yes + +diff --git a/docs_build/dev/reports/persistent_lane_manifest_report.md b/docs_build/dev/reports/persistent_lane_manifest_report.md +index f3f131d4a..30fff9f34 100644 +--- a/docs_build/dev/reports/persistent_lane_manifest_report.md ++++ b/docs_build/dev/reports/persistent_lane_manifest_report.md +@@ -1,6 +1,6 @@ + # Persistent Lane Manifest Report + +-Generated: 2026-06-21T00:10:10.218Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + Manifest directory: docs_build/dev/reports/lane_manifests + +@@ -15,13 +15,13 @@ Prevented discovery scans: 0 + + | Lane | Status | Manifest Path | Input Hash | Manifest Hash | Reason | + | --- | --- | --- | --- | --- | --- | +-| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_manifests/workspace-contract.json | 5145d05e2885e902 | d98d3245976c92f4 | Persistent manifest input hash changed for workspace-contract.; Persistent manifest hash changed for workspace-contract. | ++| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_manifests/workspace-contract.json | 61358ffa71dc7104 | fdada39983df06d5 | Persistent manifest lane definition hash changed for workspace-contract. | + + ## Persisted Manifest Files + + | Lane | Ownership | Source | Tests | Helpers | Fixtures | Dependency Graph Hash | Manifest Hash | + | --- | --- | --- | --- | --- | --- | --- | --- | +-| workspace-contract | tools | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | 53e56ebae6e84541 | d98d3245976c92f4 | ++| workspace-contract | tools | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | 4dcb31ccda1bdb5a | fdada39983df06d5 | + + ## Fast-Fail Enforcement + +diff --git a/docs_build/dev/reports/playwright_discovery_ownership_report.md b/docs_build/dev/reports/playwright_discovery_ownership_report.md +index a5a1b3dea..b9ff99fa1 100644 +--- a/docs_build/dev/reports/playwright_discovery_ownership_report.md ++++ b/docs_build/dev/reports/playwright_discovery_ownership_report.md +@@ -1,6 +1,6 @@ + # Playwright Discovery Ownership Report + +-Generated: 2026-06-21T00:10:10.169Z ++Generated: 2026-06-23T16:38:48.277Z + Status: PASS + + ## Discovery-Time Ownership +diff --git a/docs_build/dev/reports/playwright_discovery_scope_report.md b/docs_build/dev/reports/playwright_discovery_scope_report.md +index 7ba7a7a0e..9aac01c6c 100644 +--- a/docs_build/dev/reports/playwright_discovery_scope_report.md ++++ b/docs_build/dev/reports/playwright_discovery_scope_report.md +@@ -1,6 +1,6 @@ + # Playwright Discovery Scope Report + +-Generated: 2026-06-21T00:10:10.176Z ++Generated: 2026-06-23T16:38:48.279Z + Status: PASS + Scoped discovery: Yes + +diff --git a/docs_build/dev/reports/playwright_structure_audit.md b/docs_build/dev/reports/playwright_structure_audit.md +index dc6386691..acc8a33b1 100644 +--- a/docs_build/dev/reports/playwright_structure_audit.md ++++ b/docs_build/dev/reports/playwright_structure_audit.md +@@ -1,6 +1,6 @@ + # Playwright Structure Audit + +-Generated: 2026-06-21T00:10:10.141Z ++Generated: 2026-06-23T16:38:48.260Z + Status: PASS + + ## Lane Directories +diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt +index 86cc56dfe..5a0ac58e8 100644 +--- a/docs_build/dev/reports/playwright_v8_coverage_report.txt ++++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt +@@ -17,13 +17,26 @@ Exercised tool entry points detected: + (0%) Theme V2 Shared JS - not exercised by this Playwright run + + Changed runtime JS files covered: +-(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only + + Files with executed line/function counts where available: + (100%) none - no covered runtime files + + Uncovered or low-coverage changed JS files: +-(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: uncovered changed runtime JS file; advisory only ++(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: uncovered changed runtime JS file; advisory only ++(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only ++(0%) toolbox/text-to-speech/text2speech.js - WARNING: uncovered changed runtime JS file; advisory only ++(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: uncovered changed runtime JS file; advisory only + + Changed JS files considered: +-(0%) src/dev-runtime/server/local-api-router.mjs - changed JS file not collected as browser runtime coverage ++(0%) src/dev-runtime/messages/messages-postgres-service.mjs - changed JS file not collected as browser runtime coverage ++(0%) tests/dev-runtime/DbSeedIntegrity.test.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/tools/MessagesPlaybackSource.test.mjs - changed JS file not collected as browser runtime coverage ++(0%) tests/tools/Text2SpeechShell.test.mjs - changed JS file not collected as browser runtime coverage ++(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage ++(0%) toolbox/text-to-speech/text2speech.js - changed JS file not collected as browser runtime coverage ++(0%) toolbox/text-to-speech/tts-profile-store.js - changed JS file not collected as browser runtime coverage +diff --git a/docs_build/dev/reports/retry_suppression_report.md b/docs_build/dev/reports/retry_suppression_report.md +index 5391d7a38..ec68cd7e2 100644 +--- a/docs_build/dev/reports/retry_suppression_report.md ++++ b/docs_build/dev/reports/retry_suppression_report.md +@@ -1,7 +1,7 @@ + # Retry Suppression Report + +-Generated: 2026-06-21T00:11:18.107Z +-Status: PASS ++Generated: 2026-06-23T16:38:57.091Z ++Status: WARN + + ## Summary + +@@ -15,7 +15,7 @@ Prevented repeated lane hydration: 0 + + | Fingerprint | Lane | Category | Retry Decision | Reason | + | --- | --- | --- | --- | --- | +-| none | none | none | No retry needed | No failures were observed. | ++| fc8ad6ba552baa70 | workspace-contract | runtime failure | Allowed only on explicit targeted retry | Retry is allowed only when explicitly requested and must preserve the same targeted lane scope. | + + ## Enforcement Rules + +diff --git a/docs_build/dev/reports/slow_path_pruning_report.md b/docs_build/dev/reports/slow_path_pruning_report.md +index 0fc071745..c56867eb8 100644 +--- a/docs_build/dev/reports/slow_path_pruning_report.md ++++ b/docs_build/dev/reports/slow_path_pruning_report.md +@@ -1,13 +1,13 @@ + # Slow Path Pruning Report + +-Generated: 2026-06-21T00:11:18.108Z ++Generated: 2026-06-23T16:38:57.092Z + Status: PASS + Source timing evidence: docs_build/dev/reports/test_cleanup_performance_report.md (2026-05-26T21:18:42.199Z) + + ## Before / After Runtime Observations + + PR_26146_038 measured lane elapsed time: 169.71s +-Current measured lane elapsed time: 67.79s ++Current measured lane elapsed time: 8.76s + PR_26146_038 actual browser launches: 4 + Current actual browser launches: 1 + Accidental no-argument browser launches prevented: 5 +@@ -31,16 +31,16 @@ Validation cache hits: 18 + | PR_26146_038 | tool-runtime | 19.10s | Asset Manager V2 temporary UAT context | + | PR_26146_038 | integration | 14.50s | games index resolves Pong thumbnail from manifest preview role | + | PR_26146_038 | tool-runtime | 10.10s | Preview Generator V2 real batch output | +-| current targeted run | workspace-contract | 15.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | +-| current targeted run | workspace-contract | 14.70s | tests\playwright\tools\RootToolsFutureState.spec.mjs:664:1 > representative active tool pages align center cleanup and registry group colors | +-| current targeted run | workspace-contract | 13.10s | tests\playwright\tools\RootToolsFutureState.spec.mjs:562:1 > learn wireframe pages load with shared Theme V2 structure | +-| current targeted run | workspace-contract | 11.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:482:1 > common header renders primary navigation order across active pages | +-| current targeted run | workspace-contract | 2.30s | tests\playwright\tools\RootToolsFutureState.spec.mjs:641:1 > tool template future-state page loads from root Theme V2 paths | ++| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | ++| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:485:1 > common header renders primary navigation order across active pages | ++| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:565:1 > learn wireframe pages load with shared Theme V2 structure | ++| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:644:1 > tool template future-state page loads from root Theme V2 paths | ++| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:667:1 > representative active tool pages align center cleanup and registry group colors | + + ## Guardrails + + Full samples smoke: SKIP - Skipped because changed files do not modify sample JSON or shared sample loader/framework behavior. +-Runtime failures observed: 0 ++Runtime failures observed: 1 + Runtime schedule status: PASS + + - Only no-argument broad defaults and safe Workspace legacy routing were pruned. +diff --git a/docs_build/dev/reports/static_validation_report.md b/docs_build/dev/reports/static_validation_report.md +index ef23805b9..1e75365b8 100644 +--- a/docs_build/dev/reports/static_validation_report.md ++++ b/docs_build/dev/reports/static_validation_report.md +@@ -1,6 +1,6 @@ + # Static Validation Report + +-Generated: 2026-06-21T00:10:10.202Z ++Generated: 2026-06-23T16:38:48.289Z + Status: PASS + Static only: No + Dry run: No +@@ -22,7 +22,7 @@ Reason: No deterministic static validation failure was found. + | invalid filename detection | PASS | Covered by Playwright structure audit. | + | missing import detection | PASS | Covered by Playwright structure audit relative import checks. | + | missing fixture detection | PASS | No missing fixture findings. | +-| targeted file manifests | PASS | workspace-contract:d98d3245976c92f4 | ++| targeted file manifests | PASS | workspace-contract:fdada39983df06d5 | + | persistent lane manifests | PASS | workspace-contract:INVALIDATED | + | lane warm-start reuse | PASS | workspace-contract:INVALIDATED | + | dependency hydration reuse | PASS | workspace-contract:INVALIDATED | +diff --git a/docs_build/dev/reports/targeted_file_manifest_report.md b/docs_build/dev/reports/targeted_file_manifest_report.md +index fd6fbb064..0c90657e2 100644 +--- a/docs_build/dev/reports/targeted_file_manifest_report.md ++++ b/docs_build/dev/reports/targeted_file_manifest_report.md +@@ -1,13 +1,13 @@ + # Targeted File Manifest Report + +-Generated: 2026-06-21T00:10:10.217Z ++Generated: 2026-06-23T16:38:48.296Z + Status: PASS + + ## Manifest-Generated Lane Inputs + + | Lane | Ownership | Status | Source | Tests | Helpers | Fixtures | Imports / Dependencies | Dependency Graph Hash | Manifest Hash | Reason | + | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +-| workspace-contract | tools | PASS | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | 53e56ebae6e84541 | d98d3245976c92f4 | Manifest ownership, helpers, fixtures, imports, and command targets are deterministic before runtime. | ++| workspace-contract | tools | PASS | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | 4dcb31ccda1bdb5a | fdada39983df06d5 | Manifest ownership, helpers, fixtures, imports, and command targets are deterministic before runtime. | + + ## Discovery Expansion Control + +diff --git a/docs_build/dev/reports/test_cleanup_performance_report.md b/docs_build/dev/reports/test_cleanup_performance_report.md +index db3a814e7..00df617c4 100644 +--- a/docs_build/dev/reports/test_cleanup_performance_report.md ++++ b/docs_build/dev/reports/test_cleanup_performance_report.md +@@ -1,11 +1,11 @@ + # Test Cleanup Performance Report + +-Generated: 2026-06-21T00:11:18.107Z +-Status: PASS ++Generated: 2026-06-23T16:38:57.091Z ++Status: WARN + + ## Cost Summary + +-Total measured lane elapsed time: 67.79s ++Total measured lane elapsed time: 8.76s + Actual browser launch count: 1 + Scheduled browser launch count: 1 + Baseline browser launch count: 1 +@@ -23,8 +23,8 @@ Prevented redundant dependency traversal: 0 + + | Lane | Status | Elapsed | Browser Launches | Reason | + | --- | --- | --- | --- | --- | +-| workspace-contract | PASS | 67.79s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | +-| game-workspace | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | ++| workspace-contract | FAIL | 8.76s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | ++| game-hub | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | + | game-design | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | + | game-configuration | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | + | asset-tool | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | +@@ -43,11 +43,11 @@ Prevented redundant dependency traversal: 0 + + | Lane | Duration | Test | Command | + | --- | --- | --- | --- | +-| workspace-contract | 15.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +-| workspace-contract | 14.70s | tests\playwright\tools\RootToolsFutureState.spec.mjs:664:1 > representative active tool pages align center cleanup and registry group colors | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +-| workspace-contract | 13.10s | tests\playwright\tools\RootToolsFutureState.spec.mjs:562:1 > learn wireframe pages load with shared Theme V2 structure | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +-| workspace-contract | 11.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:482:1 > common header renders primary navigation order across active pages | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +-| workspace-contract | 2.30s | tests\playwright\tools\RootToolsFutureState.spec.mjs:641:1 > tool template future-state page loads from root Theme V2 paths | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:485:1 > common header renders primary navigation order across active pages | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:565:1 > learn wireframe pages load with shared Theme V2 structure | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:644:1 > tool template future-state page loads from root Theme V2 paths | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:667:1 > representative active tool pages align center cleanup and registry group colors | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | + + ## Prevented Broad Execution + +diff --git a/docs_build/dev/reports/test_cleanup_routing_report.md b/docs_build/dev/reports/test_cleanup_routing_report.md +index ed7ec4550..542854bf0 100644 +--- a/docs_build/dev/reports/test_cleanup_routing_report.md ++++ b/docs_build/dev/reports/test_cleanup_routing_report.md +@@ -1,6 +1,6 @@ + # Test Cleanup Routing Report + +-Generated: 2026-06-21T00:11:18.108Z ++Generated: 2026-06-23T16:38:57.092Z + Status: PASS + + ## Representative Routing Cases +@@ -33,7 +33,7 @@ Full samples smoke decision: SKIP - Skipped because changed files do not modify + | test:lane:tool-images | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane tool-images | + | test:lane:game-configuration | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-configuration | + | test:lane:game-design | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-design | +-| test:lane:game-workspace | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-workspace | ++| test:lane:game-hub | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-hub | + | test:lane:tool-runtime | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane tool-runtime | + | test:lane:game-runtime | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-runtime | + | test:lane:integration | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane integration | +diff --git a/docs_build/dev/reports/testing_lane_execution_report.md b/docs_build/dev/reports/testing_lane_execution_report.md +index c9cbce468..03e150e46 100644 +--- a/docs_build/dev/reports/testing_lane_execution_report.md ++++ b/docs_build/dev/reports/testing_lane_execution_report.md +@@ -1,15 +1,15 @@ + # Testing Lane Execution Report + +-Generated: 2026-06-21T00:11:18.159Z ++Generated: 2026-06-23T16:38:57.102Z + Dry run: No + + ## Summary + +-PASS: 1 ++PASS: 0 + WARN: 0 +-FAIL: 0 ++FAIL: 1 + SKIP: 14 +-Total lane elapsed time: 67.79s ++Total lane elapsed time: 8.76s + Actual browser launches: 1 + + ## Full Samples Smoke +@@ -21,7 +21,7 @@ Reason: Skipped because changed files do not modify sample JSON or shared sample + + Status: PASS + Reason: Runner preflight and Playwright structure audit passed before expensive lane execution. +-Command: C:\nvm4w\nodejs\node.exe scripts/audit-playwright-test-locations.mjs --discovery-report docs_build/dev/reports/playwright_discovery_ownership_report.md --scope-report docs_build/dev/reports/playwright_discovery_scope_report.md --scan-report docs_build/dev/reports/filesystem_scan_reduction_report.md --lanes workspace-contract --targets tests/playwright/tools/RootToolsFutureState.spec.mjs --helpers tests/helpers/playwrightRepoServer.mjs,tests/helpers/playwrightStorageIsolation.mjs,tests/helpers/playwrightV8CoverageReporter.mjs,tests/helpers/workspaceV2CoverageReporter.mjs ++Command: "C:\\Program Files\\nodejs\\node.exe" scripts/audit-playwright-test-locations.mjs --discovery-report docs_build/dev/reports/playwright_discovery_ownership_report.md --scope-report docs_build/dev/reports/playwright_discovery_scope_report.md --scan-report docs_build/dev/reports/filesystem_scan_reduction_report.md --lanes workspace-contract --targets tests/playwright/tools/RootToolsFutureState.spec.mjs --helpers tests/helpers/playwrightRepoServer.mjs,tests/helpers/playwrightStorageIsolation.mjs,tests/helpers/playwrightV8CoverageReporter.mjs,tests/helpers/workspaceV2CoverageReporter.mjs + Details: none + + ## Dependency Gate +@@ -49,9 +49,9 @@ Validation computations: 10 + + ## Failure Fingerprints + +-Status: PASS ++Status: WARN + Deterministic setup failures: 0 +-Runtime failures: 0 ++Runtime failures: 1 + Flaky/transient failures: 0 + Infrastructure failures: 0 + Prevented reruns: 0 +@@ -105,15 +105,15 @@ Prevented Workspace lane reruns: 0 + + | Lane | Status | Elapsed | Browser Launches | Executed/Skipped Reason | Affected Surface | Fixtures / Inputs | + | --- | --- | --- | --- | --- | --- | --- | +-| workspace-contract | PASS | 67.79s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | Root tools future-state navigation and Tool Template V2 contract | repo-served root tools page; Tool Template V2 future-state page; Theme V2 shared partials and assets | +-| game-workspace | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Workspace mock repository, Game Workspace UI, and Toolbox Progress/Build Path game-state bridge | repo-served Game Workspace page; repo-served Toolbox page with role simulation; in-memory SQL-shaped mock game repository | +-| game-design | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | repo-served Game Design page; repo-served Toolbox Progress and Build Path views; in-memory SQL-shaped Game Design mock repository; Game Workspace mock game context | ++| workspace-contract | FAIL | 8.76s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | Root tools future-state navigation and Tool Template V2 contract | repo-served root tools page; Tool Template V2 future-state page; Theme V2 shared partials and assets | ++| game-hub | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Hub mock repository, Game Hub UI, and Toolbox Progress/Build Path game-state bridge | repo-served Game Hub page; repo-served Toolbox page with role simulation; in-memory SQL-shaped mock game repository | ++| game-design | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | repo-served Game Design page; repo-served Toolbox Progress and Build Path views; in-memory SQL-shaped Game Design mock repository; Game Hub mock game context | + | game-configuration | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | repo-served Game Configuration page; repo-served Game Design page for handoff checks; repo-served Toolbox Progress and Build Path views; in-memory SQL-shaped Game Configuration mock repository; Game Design mock repository handoff | + | asset-tool | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | repo-served Assets page; in-memory SQL-shaped Asset Tool mock repository; Game Configuration mock repository handoff; file-name/path-based import preview | +-| build-path | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | repo-served Toolbox page; repo-served Admin Tools Progress page; Game Workspace mock game context; Toolbox role simulation | ++| build-path | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | repo-served Toolbox page; repo-served Admin Tools Progress page; Game Hub mock game context; Toolbox role simulation | + | tools-progress | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Admin Tools Progress hydration, Toolbox Group view color model, and Game Build Path separation | repo-served Admin Tools Progress page; repo-served Toolbox Group view; Toolbox registry build sequence; Game Build Path workflow table | +-| tool-navigation | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | repo-served Admin Tools Progress page; repo-served Game Workspace, Game Design, and Game Configuration tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata | +-| tool-display-mode | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | repo-served Game Workspace, Game Design, Game Configuration, and AI Assistant tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata; shared Theme V2 Tool Display Mode script | ++| tool-navigation | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | repo-served Admin Tools Progress page; repo-served Game Hub, Game Design, and Game Configuration tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata | ++| tool-display-mode | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | repo-served Game Hub, Game Design, Game Configuration, and AI Assistant tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata; shared Theme V2 Tool Display Mode script | + | tool-images | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Toolbox registry image contract, Toolbox card image rendering, and Tool Display Mode image fallback | Toolbox registry badge/tool image contract; repo-served Toolbox page; repo-served representative Toolbox tool pages; shared registry image fallback | + | tool-runtime | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Active public toolbox and Tool Template V2 contract | repo-served root toolbox page; Tool Template V2 public page; Theme V2 shared partials and assets | + | game-runtime | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Deprecated archive/v1-v2/games reference coverage | | +@@ -125,18 +125,18 @@ Prevented Workspace lane reruns: 0 + + | Lane | Duration | Test | + | --- | --- | --- | +-| workspace-contract | 15.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | +-| workspace-contract | 14.70s | tests\playwright\tools\RootToolsFutureState.spec.mjs:664:1 > representative active tool pages align center cleanup and registry group colors | +-| workspace-contract | 13.10s | tests\playwright\tools\RootToolsFutureState.spec.mjs:562:1 > learn wireframe pages load with shared Theme V2 structure | +-| workspace-contract | 11.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:482:1 > common header renders primary navigation order across active pages | +-| workspace-contract | 2.30s | tests\playwright\tools\RootToolsFutureState.spec.mjs:641:1 > tool template future-state page loads from root Theme V2 paths | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:485:1 > common header renders primary navigation order across active pages | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:565:1 > learn wireframe pages load with shared Theme V2 structure | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:644:1 > tool template future-state page loads from root Theme V2 paths | ++| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:667:1 > representative active tool pages align center cleanup and registry group colors | + + ## Commands + + ### workspace-contract +-- PASS 67.79s C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list ++- FAIL 8.76s "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list + +-### game-workspace ++### game-hub + - SKIP + + ### game-design +diff --git a/docs_build/dev/reports/validation_cache_report.md b/docs_build/dev/reports/validation_cache_report.md +index 2afd84b03..f9d0e606d 100644 +--- a/docs_build/dev/reports/validation_cache_report.md ++++ b/docs_build/dev/reports/validation_cache_report.md +@@ -1,6 +1,6 @@ + # Validation Cache Report + +-Generated: 2026-06-21T00:10:10.220Z ++Generated: 2026-06-23T16:38:48.297Z + Status: PASS + + ## Cache Summary +@@ -12,34 +12,34 @@ Validations computed: 10 + + | Stage | Cache | Input Hash | Reused By | Invalidation Inputs | + | --- | --- | --- | --- | --- | +-| lane registration validation | MISS | 6a5b13bb47c3fad3 | initial computation | lane definitions change; package.json lane scripts change | +-| runner preflight validation | MISS | 0633df51b798654a | initial computation | lane definitions change; fixture ownership changes; targeted files change | +-| scoped discovery map | MISS | e195476e1978ec54 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | +-| targeted file manifest validation | MISS | c5c4b7876c948959 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | +-| lane warm-start validation | MISS | a00444576ec05d24 | initial computation | lane definitions change; targeted files change; ownership metadata changes; dependency graph changes; helper/fixture placement changes; lane configuration changes | +-| structural ownership validation | MISS | f0b916009d8b7a2e | initial computation | fixture ownership changes; helper/import graph changes; targeted files change | +-| lane compilation validation | MISS | 0e1346462ea720dd | initial computation | lane definitions change; targeted files change; fixture ownership changes | +-| lane compilation validation | HIT | 0e1346462ea720dd | dependency validation input | unchanged within execution cycle | +-| dependency validation | MISS | 94d1bf1c7924d46d | initial computation | dependency graph changes; lane definitions change; lane compilation input changes | +-| lane snapshot validation | MISS | 7ebcc49fd0ce56c8 | initial computation | targeted files change; dependency graph changes; helper/fixture ownership changes; lane configuration changes; runtime configuration changes | +-| zero-browser preflight | MISS | 568caf8342cb4bc0 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change; dependency graph changes | +-| structural ownership validation | HIT | f0b916009d8b7a2e | static validation report | unchanged within execution cycle | +-| structural ownership validation | HIT | f0b916009d8b7a2e | zero-browser preflight report | unchanged within execution cycle | +-| scoped discovery map | HIT | e195476e1978ec54 | structural ownership validation input | unchanged within execution cycle | +-| scoped discovery map | HIT | e195476e1978ec54 | discovery scope reporting | unchanged within execution cycle | +-| targeted file manifest validation | HIT | c5c4b7876c948959 | lane input validation report | unchanged within execution cycle | +-| targeted file manifest validation | HIT | c5c4b7876c948959 | runtime scheduling blockers | unchanged within execution cycle | +-| lane warm-start validation | HIT | a00444576ec05d24 | warm-start report | unchanged within execution cycle | +-| lane warm-start validation | HIT | a00444576ec05d24 | dependency hydration reuse report | unchanged within execution cycle | +-| lane warm-start validation | HIT | a00444576ec05d24 | runtime scheduling | unchanged within execution cycle | +-| lane snapshot validation | HIT | 7ebcc49fd0ce56c8 | lane snapshot report | unchanged within execution cycle | +-| lane snapshot validation | HIT | 7ebcc49fd0ce56c8 | execution graph reuse report | unchanged within execution cycle | +-| lane snapshot validation | HIT | 7ebcc49fd0ce56c8 | runtime scheduling | unchanged within execution cycle | +-| lane compilation validation | HIT | 0e1346462ea720dd | lane compilation report | unchanged within execution cycle | +-| lane compilation validation | HIT | 0e1346462ea720dd | runtime scheduling | unchanged within execution cycle | +-| dependency validation | HIT | 94d1bf1c7924d46d | dependency report | unchanged within execution cycle | +-| dependency validation | HIT | 94d1bf1c7924d46d | runtime scheduling | unchanged within execution cycle | +-| zero-browser preflight | HIT | 568caf8342cb4bc0 | zero-browser report output | unchanged within execution cycle | ++| lane registration validation | MISS | 52928e5ef56fae1e | initial computation | lane definitions change; package.json lane scripts change | ++| runner preflight validation | MISS | b766210e324f884f | initial computation | lane definitions change; fixture ownership changes; targeted files change | ++| scoped discovery map | MISS | 3b6288f10251cb39 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | ++| targeted file manifest validation | MISS | 3f6048ccdda17a8d | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | ++| lane warm-start validation | MISS | e24f0d440b1410dc | initial computation | lane definitions change; targeted files change; ownership metadata changes; dependency graph changes; helper/fixture placement changes; lane configuration changes | ++| structural ownership validation | MISS | ecc95a2940cc1427 | initial computation | fixture ownership changes; helper/import graph changes; targeted files change | ++| lane compilation validation | MISS | d9318e7005141134 | initial computation | lane definitions change; targeted files change; fixture ownership changes | ++| lane compilation validation | HIT | d9318e7005141134 | dependency validation input | unchanged within execution cycle | ++| dependency validation | MISS | 22637e871065d383 | initial computation | dependency graph changes; lane definitions change; lane compilation input changes | ++| lane snapshot validation | MISS | a80d6a2d403c317b | initial computation | targeted files change; dependency graph changes; helper/fixture ownership changes; lane configuration changes; runtime configuration changes | ++| zero-browser preflight | MISS | 56c385cc0885a49f | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change; dependency graph changes | ++| structural ownership validation | HIT | ecc95a2940cc1427 | static validation report | unchanged within execution cycle | ++| structural ownership validation | HIT | ecc95a2940cc1427 | zero-browser preflight report | unchanged within execution cycle | ++| scoped discovery map | HIT | 3b6288f10251cb39 | structural ownership validation input | unchanged within execution cycle | ++| scoped discovery map | HIT | 3b6288f10251cb39 | discovery scope reporting | unchanged within execution cycle | ++| targeted file manifest validation | HIT | 3f6048ccdda17a8d | lane input validation report | unchanged within execution cycle | ++| targeted file manifest validation | HIT | 3f6048ccdda17a8d | runtime scheduling blockers | unchanged within execution cycle | ++| lane warm-start validation | HIT | e24f0d440b1410dc | warm-start report | unchanged within execution cycle | ++| lane warm-start validation | HIT | e24f0d440b1410dc | dependency hydration reuse report | unchanged within execution cycle | ++| lane warm-start validation | HIT | e24f0d440b1410dc | runtime scheduling | unchanged within execution cycle | ++| lane snapshot validation | HIT | a80d6a2d403c317b | lane snapshot report | unchanged within execution cycle | ++| lane snapshot validation | HIT | a80d6a2d403c317b | execution graph reuse report | unchanged within execution cycle | ++| lane snapshot validation | HIT | a80d6a2d403c317b | runtime scheduling | unchanged within execution cycle | ++| lane compilation validation | HIT | d9318e7005141134 | lane compilation report | unchanged within execution cycle | ++| lane compilation validation | HIT | d9318e7005141134 | runtime scheduling | unchanged within execution cycle | ++| dependency validation | HIT | 22637e871065d383 | dependency report | unchanged within execution cycle | ++| dependency validation | HIT | 22637e871065d383 | runtime scheduling | unchanged within execution cycle | ++| zero-browser preflight | HIT | 56c385cc0885a49f | zero-browser report output | unchanged within execution cycle | + + ## Deterministic Invalidation Rules + +diff --git a/docs_build/dev/reports/zero_browser_preflight_report.md b/docs_build/dev/reports/zero_browser_preflight_report.md +index 6b9f2cec7..2417f5424 100644 +--- a/docs_build/dev/reports/zero_browser_preflight_report.md ++++ b/docs_build/dev/reports/zero_browser_preflight_report.md +@@ -1,6 +1,6 @@ + # Zero-Browser Preflight Report + +-Generated: 2026-06-21T00:10:10.219Z ++Generated: 2026-06-23T16:38:48.297Z + Status: PASS + + ## Prevented Browser Launches diff --git a/src/dev-runtime/messages/messages-postgres-service.mjs b/src/dev-runtime/messages/messages-postgres-service.mjs -index e52e3632d..a326dc9ae 100644 +index a326dc9ae..0d077b530 100644 --- a/src/dev-runtime/messages/messages-postgres-service.mjs +++ b/src/dev-runtime/messages/messages-postgres-service.mjs -@@ -1,4 +1,8 @@ +@@ -1,8 +1,4 @@ import { randomBytes } from "node:crypto"; -+import { -+ createMessageStudioDefaultTtsProfiles, -+ createMessageStudioTtsProfileOptions, -+} from "../../../toolbox/text-to-speech/text2speech.js"; +-import { +- createMessageStudioDefaultTtsProfiles, +- createMessageStudioTtsProfileOptions, +-} from "../../../toolbox/text-to-speech/text2speech.js"; import { createPostgresConnectionClient } from "../persistence/postgres-connection-client.mjs"; import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; -@@ -14,6 +18,7 @@ const SEED_CATEGORY_NAMES = Object.freeze([ - "Notification", - ]); - const SEED_EMOTION_PROFILES = Object.freeze([ -+ Object.freeze({ description: "Balanced spoken delivery for general narration or dialog.", name: "Neutral", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), - Object.freeze({ description: "Neutral spoken delivery for general narration or dialog.", name: "Calm", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), - Object.freeze({ description: "Fast, alert delivery for warnings and immediate danger.", name: "Urgent", pauseAfterMs: 80, pauseBeforeMs: 0, pitch: 1.08, rate: 1.15, volume: 1 }), - Object.freeze({ description: "Quiet delivery for secret, stealth, or intimate lines.", name: "Whisper", pauseAfterMs: 180, pauseBeforeMs: 80, pitch: 0.95, rate: 0.9, volume: 0.55 }), -@@ -21,79 +26,23 @@ const SEED_EMOTION_PROFILES = Object.freeze([ - Object.freeze({ description: "Bright delivery for reveals, wins, and high-energy moments.", name: "Excited", pauseAfterMs: 100, pauseBeforeMs: 0, pitch: 1.12, rate: 1.12, volume: 1 }), - Object.freeze({ description: "Soft delivery for loss, regret, or reflective moments.", name: "Sad", pauseAfterMs: 220, pauseBeforeMs: 100, pitch: 0.9, rate: 0.85, volume: 0.8 }), +@@ -28,21 +24,11 @@ const SEED_EMOTION_PROFILES = Object.freeze([ Object.freeze({ description: "Measured delivery for suspense, hidden lore, or strange events.", name: "Mysterious", pauseAfterMs: 260, pauseBeforeMs: 120, pitch: 0.92, rate: 0.88, volume: 0.85 }), -+ Object.freeze({ description: "Synthetic delivery for mechanical or artificial characters.", name: "Robot", pauseAfterMs: 120, pauseBeforeMs: 40, pitch: 0.82, rate: 0.92, volume: 0.9 }), + Object.freeze({ description: "Synthetic delivery for mechanical or artificial characters.", name: "Robot", pauseAfterMs: 120, pauseBeforeMs: 40, pitch: 0.82, rate: 0.92, volume: 0.9 }), ]); --const SEED_TTS_PROFILES = Object.freeze([ -- Object.freeze({ -- description: "Clear story narration voice profile for authored message playback.", -- language: "en-US", -- name: "Narrator", -- pitch: 1, -- providerKey: "browser-speech", -- rate: 1, -- voiceName: "Browser default", -- volume: 1, -- }), -- Object.freeze({ -- description: "Confident player-facing hero voice profile.", -- language: "en-US", -- name: "Hero", -- pitch: 1.04, -- providerKey: "browser-speech", -- rate: 1.02, -- voiceName: "Browser hero", -- volume: 1, -- }), -- Object.freeze({ -- description: "Friendly vendor dialog voice profile.", -- language: "en-US", -- name: "Merchant", -- pitch: 0.98, -- providerKey: "browser-speech", -- rate: 0.95, -- voiceName: "Browser merchant", -- volume: 1, -- }), -- Object.freeze({ -- description: "Synthetic utility character voice profile.", -- language: "en-US", -- name: "Robot", -- pitch: 0.88, -- providerKey: "browser-speech", -- rate: 0.9, -- voiceName: "Browser robot", -- volume: 0.9, -- }), -- Object.freeze({ -- description: "Low creature dialog voice profile.", -- language: "en-US", -- name: "Monster", -- pitch: 0.78, -- providerKey: "browser-speech", -- rate: 0.86, -- voiceName: "Browser monster", -- volume: 1, -- }), -- Object.freeze({ -- description: "Balanced local browser playback option until authored TTS profiles are available.", -- language: "en-US", -- name: "Default Balanced TTS Profile", -- pitch: 1, -- providerKey: "browser-speech", -- rate: 1, -- voiceName: "Browser default", -- volume: 1, -- }), -- Object.freeze({ -- description: "Narration-focused preview configuration for future spoken story text.", -- language: "en-US", -- name: "Narration Preview", -- pitch: 0.95, -- providerKey: "browser-speech", -- rate: 0.9, -- voiceName: "Browser narration", -- volume: 0.9, -- }), --]); -+const MESSAGE_STUDIO_TTS_PROFILE_OPTIONS = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) -+ .map((profile) => Object.freeze({ -+ ...profile, -+ emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), -+ }))); -+const SEED_TTS_PROFILES = Object.freeze(MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.map((profile) => Object.freeze({ -+ description: `${profile.name} from Text To Speech profile ownership.`, -+ language: profile.language || "en-US", -+ name: profile.name, -+ pitch: 1, -+ providerKey: profile.providerKey || "browser-speech", -+ rate: 1, -+ voiceName: profile.voiceName || "Default browser voice", -+ volume: 1, -+}))); +-const MESSAGE_STUDIO_TTS_PROFILE_OPTIONS = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) +- .map((profile) => Object.freeze({ +- ...profile, +- emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), +- }))); +-const SEED_TTS_PROFILES = Object.freeze(MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.map((profile) => Object.freeze({ +- description: `${profile.name} from Text To Speech profile ownership.`, +- language: profile.language || "en-US", +- name: profile.name, +- pitch: 1, +- providerKey: profile.providerKey || "browser-speech", +- rate: 1, +- voiceName: profile.voiceName || "Default browser voice", +- volume: 1, +-}))); ++const SEED_TTS_PROFILES = Object.freeze([ ++ Object.freeze({ description: "Default Text To Speech browser profile.", language: "en-US", name: "Default Balanced Profile", pitch: 1, providerKey: "browser-speech", rate: 1, voiceName: "Default browser voice", volume: 1 }), ++ Object.freeze({ description: "Starter Text To Speech browser profile.", language: "en-US", name: "Man Profile 1", pitch: 1, providerKey: "browser-speech", rate: 1, voiceName: "Default browser voice", volume: 1 }), ++ Object.freeze({ description: "Starter Text To Speech browser profile.", language: "en-US", name: "Woman Profile 2", pitch: 1, providerKey: "browser-speech", rate: 1, voiceName: "Default browser voice", volume: 1 }), ++]); const SUPPORTED_TTS_PROVIDER_KEYS = Object.freeze([ "browser-speech", "elevenlabs", -@@ -408,13 +357,54 @@ function ttsEmotionSettingFromEmotionProfile(profile) { +@@ -357,54 +343,23 @@ function ttsEmotionSettingFromEmotionProfile(profile) { }; } -+function messageStudioTtsProfileOption(row) { -+ const rowName = normalizeText(row?.name).trim().toLowerCase(); -+ const rowKey = normalizeText(row?.key).trim(); -+ return MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.find((profile) => { -+ return profile.key === rowKey || normalizeText(profile.name).trim().toLowerCase() === rowName; -+ }) || null; -+} -+ -+function emotionSettingsForTtsProfileRow(row, emotionRows = []) { -+ const option = messageStudioTtsProfileOption(row); -+ if (!option) { -+ return []; -+ } -+ const activeEmotionProfiles = emotionRows -+ .map((profileRow) => emotionProfileFromRow(profileRow)) -+ .filter((profile) => profile.active !== false); -+ const byLabel = new Map(activeEmotionProfiles.map((profile) => [normalizeText(profile.name).trim().toLowerCase(), profile])); -+ const byEmotion = new Map(activeEmotionProfiles.map((profile) => [emotionSettingKey(profile.name), profile])); -+ return option.emotionSettings -+ .map((setting) => { -+ const emotionProfile = byLabel.get(normalizeText(setting.emotionLabel).trim().toLowerCase()) -+ || byEmotion.get(emotionSettingKey(setting.emotion)) -+ || null; -+ if (!emotionProfile) { -+ return null; -+ } -+ return { -+ ...ttsEmotionSettingFromEmotionProfile(emotionProfile), -+ pitch: Number(setting.pitch), -+ rate: Number(setting.rate), -+ ssmlLikePreset: setting.ssmlLikePreset || "normal", -+ volume: Number(setting.volume), -+ }; -+ }) -+ .filter(Boolean); -+} -+ +-function messageStudioTtsProfileOption(row) { +- const rowName = normalizeText(row?.name).trim().toLowerCase(); +- const rowKey = normalizeText(row?.key).trim(); +- return MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.find((profile) => { +- return profile.key === rowKey || normalizeText(profile.name).trim().toLowerCase() === rowName; +- }) || null; +-} +- +-function emotionSettingsForTtsProfileRow(row, emotionRows = []) { +- const option = messageStudioTtsProfileOption(row); +- if (!option) { +- return []; +- } +- const activeEmotionProfiles = emotionRows ++function emotionSettingsForTtsProfileRow(_row, emotionRows = []) { ++ return emotionRows + .map((profileRow) => emotionProfileFromRow(profileRow)) +- .filter((profile) => profile.active !== false); +- const byLabel = new Map(activeEmotionProfiles.map((profile) => [normalizeText(profile.name).trim().toLowerCase(), profile])); +- const byEmotion = new Map(activeEmotionProfiles.map((profile) => [emotionSettingKey(profile.name), profile])); +- return option.emotionSettings +- .map((setting) => { +- const emotionProfile = byLabel.get(normalizeText(setting.emotionLabel).trim().toLowerCase()) +- || byEmotion.get(emotionSettingKey(setting.emotion)) +- || null; +- if (!emotionProfile) { +- return null; +- } +- return { +- ...ttsEmotionSettingFromEmotionProfile(emotionProfile), +- pitch: Number(setting.pitch), +- rate: Number(setting.rate), +- ssmlLikePreset: setting.ssmlLikePreset || "normal", +- volume: Number(setting.volume), +- }; +- }) +- .filter(Boolean); ++ .filter((profile) => profile.active !== false) ++ .map((profile) => ttsEmotionSettingFromEmotionProfile(profile)); + } + function ttsProfileFromRow(row, emotionSettings = []) { -+ const profileOption = messageStudioTtsProfileOption(row); +- const profileOption = messageStudioTtsProfileOption(row); return { active: activeFromDatabase(row.active), -+ age: profileOption?.age || "", -+ ageFilter: profileOption?.ageFilter || profileOption?.age || "", +- age: profileOption?.age || "", +- ageFilter: profileOption?.ageFilter || profileOption?.age || "", ++ age: "", ++ ageFilter: "", createdAt: row.createdAt, createdBy: row.createdBy, description: row.description || "", emotionSettings, -+ gender: profileOption?.gender || "", +- gender: profileOption?.gender || "", ++ gender: "", key: row.key, language: row.language, name: row.name, -@@ -424,6 +414,7 @@ function ttsProfileFromRow(row, emotionSettings = []) { +@@ -414,7 +369,7 @@ function ttsProfileFromRow(row, emotionSettings = []) { status: activeFromDatabase(row.active) ? "Active" : "Inactive", updatedAt: row.updatedAt, updatedBy: row.updatedBy, -+ voice: profileOption?.voice || row.voiceName || "", +- voice: profileOption?.voice || row.voiceName || "", ++ voice: row.voiceName || "", voiceName: row.voiceName || "", volume: Number(row.volume), }; -@@ -808,11 +799,10 @@ export class MessagesPostgresService { - - async listTtsProfiles() { - await this.ensureReady(); -- const emotionSettings = (await this.tableRows("messages_emotion_profiles")) -- .map((profileRow) => emotionProfileFromRow(profileRow)) -- .filter((profile) => profile.active !== false) -- .map(ttsEmotionSettingFromEmotionProfile); -- return (await this.tableRows("messages_tts_profiles")).sort(compareName).map((row) => ttsProfileFromRow(row, emotionSettings)); -+ const emotionRows = await this.tableRows("messages_emotion_profiles"); -+ return (await this.tableRows("messages_tts_profiles")) -+ .sort(compareName) -+ .map((row) => ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows))); - } - - async getTtsProfile(key) { -@@ -821,11 +811,8 @@ export class MessagesPostgresService { - if (!row) { - throw httpError("Voice profile was not found.", 404); - } -- const emotionSettings = (await this.tableRows("messages_emotion_profiles")) -- .map((profileRow) => emotionProfileFromRow(profileRow)) -- .filter((profile) => profile.active !== false) -- .map(ttsEmotionSettingFromEmotionProfile); -- return ttsProfileFromRow(row, emotionSettings); -+ const emotionRows = await this.tableRows("messages_emotion_profiles"); -+ return ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows)); - } - - async findTtsProfileByNameRaw(name) { -@@ -843,9 +830,9 @@ export class MessagesPostgresService { - } - - async defaultVoiceProfileKeyRaw() { -- const narrator = await this.findTtsProfileByNameRaw("Narrator"); -- if (narrator) { -- return narrator.key; -+ const defaultProfile = await this.findTtsProfileByNameRaw("Default Balanced Profile"); -+ if (defaultProfile) { -+ return defaultProfile.key; - } - const fallback = (await this.tableRows("messages_tts_profiles")).sort(compareName)[0]; - if (!fallback) { -@@ -911,11 +898,8 @@ export class MessagesPostgresService { - volume: values.volume, - }); - const row = await this.rowByKey("messages_tts_profiles", key); -- const emotionSettings = (await this.tableRows("messages_emotion_profiles")) -- .map((profileRow) => emotionProfileFromRow(profileRow)) -- .filter((profile) => profile.active !== false) -- .map(ttsEmotionSettingFromEmotionProfile); -- return ttsProfileFromRow(row, emotionSettings); -+ const emotionRows = await this.tableRows("messages_emotion_profiles"); -+ return ttsProfileFromRow(row, emotionSettingsForTtsProfileRow(row, emotionRows)); - } - - async createTtsProfile(input = {}, actorKey = "") { diff --git a/tests/dev-runtime/DbSeedIntegrity.test.mjs b/tests/dev-runtime/DbSeedIntegrity.test.mjs -index 4b9406e50..fe3f5e70f 100644 +index fe3f5e70f..595a163d6 100644 --- a/tests/dev-runtime/DbSeedIntegrity.test.mjs +++ b/tests/dev-runtime/DbSeedIntegrity.test.mjs -@@ -95,17 +95,20 @@ test("Messages Local API seeds through the Postgres service and preserves respon - assert.ok(urgent, "Messages emotion profiles should include Urgent"); - - const ttsProfiles = await apiJson(server.baseUrl, "/api/messages/tts-profiles"); -- const narrator = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Narrator"); -- assert.ok(narrator, "Messages voice profiles should include Narrator"); -- assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced TTS Profile"), true); -- assert.equal(ttsProfiles.ttsProfiles[0].emotionSettings.some((setting) => setting.emotionLabel === "Urgent"), true); -+ const manProfile = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Man Profile 1"); -+ const womanProfile = ttsProfiles.ttsProfiles.find((profile) => profile.name === "Woman Profile 2"); -+ assert.ok(manProfile, "Messages TTS profiles should include Man Profile 1 from Text To Speech"); -+ assert.ok(womanProfile, "Messages TTS profiles should include Woman Profile 2 from Text To Speech"); -+ assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced Profile"), true); -+ assert.deepEqual(manProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Neutral", "Calm", "Urgent"]); -+ assert.deepEqual(womanProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Whisper", "Robot"]); +@@ -100,8 +100,10 @@ test("Messages Local API seeds through the Postgres service and preserves respon + assert.ok(manProfile, "Messages TTS profiles should include Man Profile 1 from Text To Speech"); + assert.ok(womanProfile, "Messages TTS profiles should include Woman Profile 2 from Text To Speech"); + assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced Profile"), true); +- assert.deepEqual(manProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Neutral", "Calm", "Urgent"]); +- assert.deepEqual(womanProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Whisper", "Robot"]); ++ assert.equal(manProfile.emotionSettings.some((setting) => setting.emotionLabel === "Neutral"), true); ++ assert.equal(womanProfile.emotionSettings.some((setting) => setting.emotionLabel === "Robot"), true); ++ assert.equal(manProfile.gender, ""); ++ assert.equal(womanProfile.gender, ""); const created = await apiJson(server.baseUrl, "/api/messages/messages", { body: JSON.stringify({ - emotionProfileKey: urgent.key, - messageText: "Postgres-backed message text.", - name: "Postgres Cutover Message", -- voiceProfileKey: narrator.key, -+ voiceProfileKey: manProfile.key, - }), - method: "POST", - }); -@@ -113,7 +116,7 @@ test("Messages Local API seeds through the Postgres service and preserves respon - assert.equal(created.message.categoryName, "Dialog"); - assert.equal(created.message.emotionProfileName, "Urgent"); - assert.equal(created.message.messageText, "Postgres-backed message text."); -- assert.equal(created.message.voiceProfileName, "Narrator"); -+ assert.equal(created.message.voiceProfileName, "Man Profile 1"); - - const segment = await apiJson(server.baseUrl, "/api/messages/segments", { - body: JSON.stringify({ -@@ -121,13 +124,13 @@ test("Messages Local API seeds through the Postgres service and preserves respon - emotionProfileKey: urgent.key, - messageKey: created.message.key, - segmentText: "Postgres-backed message part.", -- voiceProfileKey: narrator.key, -+ voiceProfileKey: manProfile.key, - }), - method: "POST", - }); - assert.equal(segment.segment.messageName, "Postgres Cutover Message"); - assert.equal(segment.segment.emotionProfileName, "Urgent"); -- assert.equal(segment.segment.voiceProfileName, "Narrator"); -+ assert.equal(segment.segment.voiceProfileName, "Man Profile 1"); - - const list = await apiJson(server.baseUrl, "/api/messages/messages"); - const listed = list.messages.find((message) => message.key === created.message.key); -diff --git a/tests/dev-runtime/MessagesPublishValidation.test.mjs b/tests/dev-runtime/MessagesPublishValidation.test.mjs -index 1afe0f8d4..0fcec6b18 100644 ---- a/tests/dev-runtime/MessagesPublishValidation.test.mjs -+++ b/tests/dev-runtime/MessagesPublishValidation.test.mjs -@@ -29,7 +29,7 @@ test("Messages publish validation passes publish-ready message configuration", a - const { service } = createServiceHarness(); - - const emotion = (await service.listEmotionProfiles()).find((profile) => profile.name === "Calm"); -- const voice = (await service.listTtsProfiles()).find((profile) => profile.name === "Narrator"); -+ const voice = (await service.listTtsProfiles()).find((profile) => profile.name === "Man Profile 1"); - assert.ok(emotion); - assert.ok(voice); - -diff --git a/tests/playwright/tools/EventsTool.spec.mjs b/tests/playwright/tools/EventsTool.spec.mjs -index 7ddf63e3b..d17be8dd4 100644 ---- a/tests/playwright/tools/EventsTool.spec.mjs -+++ b/tests/playwright/tools/EventsTool.spec.mjs -@@ -30,15 +30,15 @@ async function createMessage(server) { - const emotionResult = await jsonRequest(`${server.baseUrl}/api/messages/emotion-profiles`); - const voiceResult = await jsonRequest(`${server.baseUrl}/api/messages/tts-profiles`); - const urgent = emotionResult.payload.data.emotionProfiles.find((profile) => profile.name === "Urgent"); -- const narrator = voiceResult.payload.data.ttsProfiles.find((profile) => profile.name === "Narrator"); -+ const ttsProfile = voiceResult.payload.data.ttsProfiles.find((profile) => profile.name === "Man Profile 1"); - expect(urgent).toBeTruthy(); -- expect(narrator).toBeTruthy(); -+ expect(ttsProfile).toBeTruthy(); - const messageResult = await jsonRequest(`${server.baseUrl}/api/messages/messages`, { - body: JSON.stringify({ - emotionProfileKey: urgent.key, - messageText: "Open the ancient door.", - name: "Door Prompt", -- voiceProfileKey: narrator.key, -+ voiceProfileKey: ttsProfile.key, - }), - method: "POST", - }); diff --git a/tests/playwright/tools/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs -index 3461f62e2..c4322ef24 100644 +index c4322ef24..cda607adc 100644 --- a/tests/playwright/tools/MessagesTool.spec.mjs +++ b/tests/playwright/tools/MessagesTool.spec.mjs -@@ -136,6 +136,15 @@ async function addSentence(page, values) { - await page.locator("[data-messages-segment-commit='__new__']").click(); - } +@@ -3,8 +3,57 @@ import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; + import { createMessagesPostgresClientStub } from "../../helpers/messagesPostgresClientStub.mjs"; + import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; + import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; ++import { TEXT_TO_SPEECH_PROFILE_STORAGE_KEY } from "../../../toolbox/text-to-speech/tts-profile-store.js"; -+async function ensureSentencesExpanded(page, messageName) { -+ const messageRow = page.locator("[data-messages-row]").filter({ hasText: messageName }); -+ if (await page.locator("[data-messages-parts-host]").count() === 0) { -+ await messageRow.getByRole("button", { name: "Sentences" }).click(); + const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; ++const SAVED_TTS_PROFILES_FIXTURE = Object.freeze([ ++ Object.freeze({ ++ active: true, ++ age: "any", ++ emotions: [ ++ Object.freeze({ active: true, emotion: "calm", emotionLabel: "Calm", id: "calm", pitch: 1, rate: 1, volume: 1 }), ++ Object.freeze({ active: true, emotion: "urgent", emotionLabel: "Urgent", id: "urgent", pitch: 1.08, rate: 1.15, volume: 1 }), ++ ], ++ gender: "neutral", ++ id: "default-balanced-profile", ++ language: "en-US", ++ name: "Default Balanced Profile", ++ providerKey: "browser-speech", ++ voice: "Browser default", ++ voiceName: "Default browser voice", ++ }), ++ Object.freeze({ ++ active: true, ++ age: "adult", ++ emotions: [ ++ Object.freeze({ active: true, emotion: "neutral", emotionLabel: "Neutral", id: "neutral", pitch: 1, rate: 1, volume: 1 }), ++ Object.freeze({ active: true, emotion: "calm", emotionLabel: "Calm", id: "calm", pitch: 1, rate: 1, volume: 1 }), ++ Object.freeze({ active: true, emotion: "urgent", emotionLabel: "Urgent", id: "urgent", pitch: 1.08, rate: 1.15, volume: 1 }), ++ ], ++ gender: "male", ++ id: "man-profile-1", ++ language: "en-US", ++ name: "Man Profile 1", ++ providerKey: "browser-speech", ++ voice: "Browser default", ++ voiceName: "Default browser voice", ++ }), ++ Object.freeze({ ++ active: true, ++ age: "adult", ++ emotions: [ ++ Object.freeze({ active: true, emotion: "whisper", emotionLabel: "Whisper", id: "whisper", pitch: 0.95, rate: 0.9, volume: 0.55 }), ++ Object.freeze({ active: true, emotion: "robot", emotionLabel: "Robot", id: "robot", pitch: 0.82, rate: 0.92, volume: 0.9 }), ++ ], ++ gender: "female", ++ id: "woman-profile-2", ++ language: "en-US", ++ name: "Woman Profile 2", ++ providerKey: "browser-speech", ++ voice: "Browser default", ++ voiceName: "Default browser voice", ++ }), ++]); + + async function jsonRequest(url, options = {}) { + const response = await fetch(url, { +@@ -111,6 +160,18 @@ async function openMessagesPage(page, options = {}) { + }, + }; + }); ++ if (options.seedSavedTtsProfiles !== false) { ++ await page.addInitScript(({ profiles, storageKey }) => { ++ window.localStorage?.setItem(storageKey, JSON.stringify({ ++ profiles, ++ updatedAt: "2026-06-23T00:00:00.000Z", ++ version: "playwright-fixture", ++ })); ++ }, { ++ profiles: options.savedTtsProfiles || SAVED_TTS_PROFILES_FIXTURE, ++ storageKey: TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, ++ }); + } -+ await expect(page.locator("[data-messages-parts-host]")).toBeVisible(); -+ return messageRow; + await workspaceV2CoverageReporter.start(page); + await page.goto(`${server.baseUrl}/tools/messages/index.html`, { waitUntil: "networkidle" }); + return failures; +@@ -161,6 +222,14 @@ async function expectPlaybackDiagnostics(page, { + await expect(log).toContainText(`Age Filter: ${ageFilter}`); + } + ++async function setRangeValue(locator, value) { ++ await locator.evaluate((input, nextValue) => { ++ input.value = nextValue; ++ input.dispatchEvent(new Event("input", { bubbles: true })); ++ input.dispatchEvent(new Event("change", { bubbles: true })); ++ }, String(value)); +} + - async function expectPlaybackDiagnostics(page, { - ageFilter = "Any", - gender = "Any", -@@ -187,7 +196,7 @@ async function voiceProfiles(server) { - return profilesResult.payload.data.ttsProfiles; - } + async function createdMessage(server, name) { + const listResult = await jsonRequest(`${server.baseUrl}/api/messages/messages`); + expect(listResult.response.ok).toBe(true); +@@ -500,7 +569,6 @@ test("Message Studio uses the approved table-first Messages structure", async ({ --async function createReferencedSentence(server, message, emotionName = "Urgent", voiceName = "Narrator") { -+async function createReferencedSentence(server, message, emotionName = "Urgent", voiceName = "Man Profile 1") { - const emotion = await emotionProfile(server, emotionName); - const voice = await voiceProfile(server, voiceName); - expect(emotion).toBeTruthy(); -@@ -225,6 +234,8 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - await expect(page.getByText("Reusable Assets", { exact: true })).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Emotion Profiles" })).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Voice Profiles" })).toHaveCount(0); -+ await expect(page.getByText("TTS Profile / Emotion Settings", { exact: true })).toHaveCount(0); -+ await expect(page.getByText("Emotion Settings", { exact: true })).toHaveCount(0); - await expect(page.getByLabel("Emotion Profiles")).toHaveCount(0); - await expect(page.getByLabel("Voice Profiles")).toHaveCount(0); - await expect(page.getByText("Message Parts", { exact: true })).toHaveCount(0); -@@ -242,6 +253,12 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - await expect(page.locator("[data-messages-row-editor='__new__']")).toBeVisible(); - await expect(page.locator("[data-messages-row-editor='__new__'] td")).toHaveCount(4); - await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]")).toBeVisible(); -+ await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile] option")).toHaveText([ -+ "Select TTS profile", -+ "Default Balanced Profile", -+ "Man Profile 1", -+ "Woman Profile 2", -+ ]); - await expect(page.locator("[data-messages-row-editor='__new__']").getByRole("button", { name: "Save" })).toBeVisible(); - await expect(page.locator("[data-messages-row-editor='__new__']").getByRole("button", { name: "Cancel" })).toBeVisible(); - -@@ -253,18 +270,18 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - - await addMessage(page, { - name: "Bat Encounter", -- ttsProfile: "Hero", -+ ttsProfile: "Man Profile 1", - }); - await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Bat Encounter."); - const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }); -- await expect(messageRow).toContainText("Hero"); -+ await expect(messageRow).toContainText("Man Profile 1"); - await expect(messageRow.getByRole("button", { name: "Sentences" })).toBeVisible(); - await expect(messageRow.getByRole("button", { name: "Edit" })).toBeVisible(); - await expect(messageRow.getByRole("button", { name: "Archive" })).toBeVisible(); - await expect(messageRow.getByRole("button", { name: "Delete" })).toBeEnabled(); - await expect(messageRow.getByRole("button", { name: "Play" })).toBeVisible(); - -- await messageRow.getByRole("button", { name: "Sentences" }).click(); -+ await ensureSentencesExpanded(page, "Bat Encounter"); - await expect(page.locator("[data-messages-parts-host]")).toBeVisible(); - const partsTable = page.getByLabel("Bat Encounter Sentences"); - await expect(partsTable.getByRole("columnheader")).toHaveText(["Order", "Text", "Emotion", "Actions"]); -@@ -273,6 +290,12 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - await page.getByRole("button", { name: "Add Sentence" }).click(); - await expect(page.locator("[data-messages-segment-editor='__new__'] td")).toHaveCount(4); - await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-voice]")).toHaveCount(0); -+ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ -+ "Select emotion", -+ "Neutral", -+ "Calm", -+ "Urgent", -+ ]); - await page.locator("[data-messages-segment-commit='__new__']").click(); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Sentence text is required."); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion is required."); -@@ -315,8 +338,9 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); - await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); - await expectPlaybackDiagnostics(page, { -- profile: "Hero", -- voice: "Browser hero", -+ gender: "Male", -+ profile: "Man Profile 1", -+ voice: "Default browser voice", - }); - - await addSentence(page, { -@@ -348,8 +372,9 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); - await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); - await expectPlaybackDiagnostics(page, { -- profile: "Hero", -- voice: "Browser hero", -+ gender: "Male", -+ profile: "Man Profile 1", -+ voice: "Default browser voice", - }); - - await page.getByRole("button", { name: "Run Validation" }).click(); -@@ -388,8 +413,9 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); - await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); - await expectPlaybackDiagnostics(page, { -- profile: "Hero", -- voice: "Browser hero", -+ gender: "Male", -+ profile: "Man Profile 1", -+ voice: "Default browser voice", - }); - await page.getByRole("button", { name: "Stop Speech" }).click(); - await expect(page.locator("[data-messages-speech-status]")).toHaveText("Speech playback stopped."); -@@ -459,24 +485,25 @@ test("Message Studio uses the approved table-first Messages structure", async ({ - 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("Saved message Bat Encounter Updated."); -- await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter Updated" })).toContainText("Hero"); -+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter Updated" })).toContainText("Man Profile 1"); - - const updatedMessage = await createdMessage(failures.server, "Bat Encounter Updated"); - expect(updatedMessage).toEqual(expect.objectContaining({ - active: true, - categoryName: "Dialog", -- voiceProfileName: "Hero", -+ voiceProfileName: "Man Profile 1", - })); - expect(updatedMessage.key).toMatch(ULID_PATTERN); - expect(updatedMessage).not.toHaveProperty("rate"); - expect(updatedMessage).not.toHaveProperty("pitch"); - expect(updatedMessage).not.toHaveProperty("volume"); - -- const heroProfile = (await voiceProfiles(failures.server)).find((profile) => profile.name === "Hero"); -- expect(heroProfile).toEqual(expect.objectContaining({ -+ const manProfile = (await voiceProfiles(failures.server)).find((profile) => profile.name === "Man Profile 1"); -+ expect(manProfile).toEqual(expect.objectContaining({ -+ gender: "male", + const manProfile = (await voiceProfiles(failures.server)).find((profile) => profile.name === "Man Profile 1"); + expect(manProfile).toEqual(expect.objectContaining({ +- gender: "male", language: "en-US", providerKey: "browser-speech", -- voiceName: "Browser hero", -+ voiceName: "Default browser voice", - })); - - await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter Updated" }).getByRole("button", { name: "Delete" }).click(); -@@ -492,13 +519,105 @@ test("Message Studio uses the approved table-first Messages structure", async ({ + voiceName: "Default browser voice", +@@ -519,6 +587,95 @@ test("Message Studio uses the approved table-first Messages structure", async ({ } }); -+test("Message Studio loads Text To Speech profiles and filters sentence emotions by selected profile", async ({ page }) => { -+ const failures = await openMessagesPage(page); ++test("Message Studio consumes active saved Text To Speech profiles", async ({ page }) => { ++ const failures = await openMessagesPage(page, { seedSavedTtsProfiles: false }); + + try { ++ await page.goto(`${failures.server.baseUrl}/tools/text-to-speech/index.html`, { waitUntil: "networkidle" }); ++ await expect(page.getByRole("heading", { level: 1, name: "Text To Speech" })).toBeVisible(); ++ ++ await page.locator("[data-tts-profile-add-control-row]").getByRole("button", { name: "Add Profile" }).click(); ++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Quest Profile Draft"); ++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("male"); ++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-age]").selectOption("adult"); ++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-voice]").selectOption("Browser guide updated"); ++ await page.locator("[data-tts-commit-profile='__new__']").click(); ++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Quest Profile Draft."); ++ ++ await page.locator("[data-tts-profile-row]").filter({ hasText: "Quest Profile Draft" }).getByRole("button", { name: "Edit Profile" }).click(); ++ await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Quest Profile Active"); ++ await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); ++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Quest Profile Active."); ++ ++ await page.locator("[data-tts-emotion-add-control-row]").getByRole("button", { name: "Add Emotion" }).click(); ++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); ++ await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]"), "1.2"); ++ await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]"), "1.1"); ++ await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]"), "0.7"); ++ await page.locator("[data-tts-commit-emotion='__new__']").click(); ++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion: Urgent."); ++ ++ await page.goto(`${failures.server.baseUrl}/tools/messages/index.html`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add Message" }).click(); -+ const profileOptions = page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile] option"); -+ await expect(profileOptions).toHaveText([ -+ "Select TTS profile", -+ "Default Balanced Profile", -+ "Man Profile 1", -+ "Woman Profile 2", -+ ]); -+ await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill("Profile Filter Test"); -+ await page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]").selectOption({ label: "Man Profile 1" }); ++ await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]")).toContainText("Quest Profile Active"); ++ await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill("Quest Profile Message"); ++ await page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]").selectOption({ label: "Quest Profile Active" }); + await page.locator("[data-messages-commit='__new__']").click(); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Profile Filter Test."); ++ await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Quest Profile Message."); + -+ await ensureSentencesExpanded(page, "Profile Filter Test"); ++ const messageRow = await ensureSentencesExpanded(page, "Quest Profile Message"); + await page.getByRole("button", { name: "Add Sentence" }).click(); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ + "Select emotion", + "Neutral", -+ "Calm", + "Urgent", + ]); -+ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Whisper"); -+ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Robot"); -+ await page.locator("[data-messages-segment-cancel='__new__']").click(); -+ -+ const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Profile Filter Test" }); -+ await messageRow.getByRole("button", { name: "Edit" }).click(); -+ await page.locator("[data-messages-row-editor] [data-message-tts-profile]").selectOption({ label: "Woman Profile 2" }); -+ await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Profile Filter Test."); -+ -+ await ensureSentencesExpanded(page, "Profile Filter Test"); -+ await page.getByRole("button", { name: "Add Sentence" }).click(); -+ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ -+ "Select emotion", -+ "Whisper", -+ "Robot", -+ ]); -+ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Calm"); -+ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Urgent"); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Happy"); ++ await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Scared"); + await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill("1"); -+ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill("Robot voice check."); -+ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: "Robot" }); ++ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill("The quest door opens."); ++ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: "Urgent" }); + await page.locator("[data-messages-segment-commit='__new__']").click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved sentence 1."); + -+ const sentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "Robot voice check." }); ++ const sentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "The quest door opens." }); + await page.evaluate(() => { + window.__spokenUtterances = []; + }); + await sentenceRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); -+ const spokenSentence = await page.evaluate(() => window.__spokenUtterances); -+ expect(spokenSentence).toEqual([expect.objectContaining({ -+ pitch: 0.82, -+ rate: 0.92, -+ text: "Robot voice check.", -+ volume: 0.9, ++ let utterances = await page.evaluate(() => window.__spokenUtterances); ++ expect(utterances).toEqual([expect.objectContaining({ ++ pitch: 1.2, ++ rate: 1.1, ++ text: "The quest door opens.", ++ volume: 0.7, + })]); + await expectPlaybackDiagnostics(page, { -+ gender: "Female", -+ profile: "Woman Profile 2", -+ voice: "Default browser voice", ++ ageFilter: "Adult", ++ gender: "Male", ++ profile: "Quest Profile Active", ++ voice: "Browser guide updated", + }); -+ await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); -+ await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + + await page.evaluate(() => { + window.__spokenUtterances = []; + }); + await messageRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); -+ const spokenMessage = await page.evaluate(() => window.__spokenUtterances); -+ expect(spokenMessage.map((utterance) => utterance.text)).toEqual(["Robot voice check."]); -+ await expectPlaybackDiagnostics(page, { -+ gender: "Female", -+ profile: "Woman Profile 2", -+ voice: "Default browser voice", -+ }); ++ utterances = await page.evaluate(() => window.__spokenUtterances); ++ expect(utterances.map((utterance) => utterance.text)).toEqual(["The quest door opens."]); ++ await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); ++ await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + + expect(failures.failedRequests).toEqual([]); + expect(failures.pageErrors).toEqual([]); @@ -575,346 +1392,745 @@ index 3461f62e2..c4322ef24 100644 + } +}); + - test("Message Studio disables Delete when a message is referenced", async ({ page }) => { + test("Message Studio loads Text To Speech profiles and filters sentence emotions by selected profile", async ({ page }) => { const failures = await openMessagesPage(page); - try { - await addMessage(page, { - name: "Referenced Encounter", -- ttsProfile: "Narrator", -+ ttsProfile: "Man Profile 1", - }); - const message = await createdMessage(failures.server, "Referenced Encounter"); - const segment = await createReferencedSentence(failures.server, message); diff --git a/tests/tools/MessagesPlaybackSource.test.mjs b/tests/tools/MessagesPlaybackSource.test.mjs -index cea5e43e1..b0389d19a 100644 +index b0389d19a..5ee7607c0 100644 --- a/tests/tools/MessagesPlaybackSource.test.mjs +++ b/tests/tools/MessagesPlaybackSource.test.mjs -@@ -9,3 +9,20 @@ test("Messages playback runtime does not include preview voice validation text", - assert.equal(source.includes("available browser voice before preview"), false); - assert.equal(source.includes("Select an available browser voice before preview"), false); - }); -+ -+test("Messages sentence emotion picker does not fall back to unrelated global emotions", async () => { -+ const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); -+ -+ assert.equal(source.includes("selectOptionsWithCurrent"), false); -+ assert.equal(source.includes("return options.length ? options :"), false); +@@ -20,9 +20,19 @@ test("Messages sentence emotion picker does not fall back to unrelated global em + test("Messages wires profile dropdowns through the Text To Speech profile contract", async () => { + const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); + +- assert.equal(source.includes("../text-to-speech/text2speech.js"), true); +- assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), true); +- assert.equal(source.includes("createMessageStudioTtsProfileOptions"), true); ++ assert.equal(source.includes("../text-to-speech/text2speech.js"), false); ++ assert.equal(source.includes("../text-to-speech/tts-profile-store.js"), true); ++ assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), false); ++ assert.equal(source.includes("createMessageStudioTtsProfileOptions"), false); + assert.equal(source.includes("state.voiceProfiles = voicePayload.ttsProfiles || []"), false); +- assert.equal(source.includes("messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || [])"), true); ++ assert.equal(source.includes("messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || [])"), false); ++ assert.equal(source.includes("activeTextToSpeechProfilesForMessages(voicePayload.ttsProfiles || [])"), true); +}); + -+test("Messages wires profile dropdowns through the Text To Speech profile contract", async () => { -+ const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); ++test("Messages dev runtime does not import browser Text To Speech UI modules", async () => { ++ const source = await readFile(new URL("../../src/dev-runtime/messages/messages-postgres-service.mjs", import.meta.url), "utf8"); + -+ assert.equal(source.includes("../text-to-speech/text2speech.js"), true); -+ assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), true); -+ assert.equal(source.includes("createMessageStudioTtsProfileOptions"), true); -+ assert.equal(source.includes("state.voiceProfiles = voicePayload.ttsProfiles || []"), false); -+ assert.equal(source.includes("messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || [])"), true); -+}); ++ assert.equal(source.includes("toolbox/text-to-speech/text2speech.js"), false); ++ assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), false); ++ assert.equal(source.includes("createMessageStudioTtsProfileOptions"), false); + }); diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs -index fa54dcbcc..eea35e2cb 100644 +index eea35e2cb..f857327f9 100644 --- a/tests/tools/Text2SpeechShell.test.mjs +++ b/tests/tools/Text2SpeechShell.test.mjs -@@ -7,6 +7,7 @@ import { +@@ -7,8 +7,6 @@ import { TTS_PROVIDER_ADAPTER_PLAN, createDefaultTextToSpeechProfiles, createEmotionProfile, -+ createMessageStudioDefaultTtsProfiles, - createMessageStudioTtsProfileOptions, +- createMessageStudioDefaultTtsProfiles, +- createMessageStudioTtsProfileOptions, createSpeechPreviewRequest, createTextToSpeechProfile, -@@ -68,6 +69,7 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai - test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { + createTextToSpeechProfileEmotion, +@@ -16,6 +14,12 @@ import { + createVoiceProfile, + previewTtsMessage, + } from "../../toolbox/text-to-speech/text2speech.js"; ++import { ++ TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, ++ readSavedTextToSpeechProfiles, ++ textToSpeechProfilesToMessageOptions, ++ writeSavedTextToSpeechProfiles, ++} from "../../toolbox/text-to-speech/tts-profile-store.js"; + + test("Text2Speech message model separates Design and Audio ownership", () => { + const message = createTtsMessage({ text: "Hello", metadata: { tags: ["intro"] } }); +@@ -66,10 +70,9 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai + assert.ok(TTS_PROVIDER_ADAPTER_PLAN.slice(1).every((provider) => provider.status === "planned")); + }); + +-test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { ++test("Text2Speech saved profile store exposes active profiles for Messages", () => { const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; const defaults = createDefaultTextToSpeechProfiles(voiceOptions); -+ const messageStudioDefaults = createMessageStudioDefaultTtsProfiles(voiceOptions); +- const messageStudioDefaults = createMessageStudioDefaultTtsProfiles(voiceOptions); const custom = createTextToSpeechProfile({ emotions: [ createTextToSpeechProfileEmotion({ -@@ -91,9 +93,13 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt +@@ -85,22 +88,34 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt + voice: "test-voice", + voiceName: "Test Voice", + }); +- const options = createMessageStudioTtsProfileOptions([custom]); ++ const writes = new Map(); ++ const storage = { ++ getItem(key) { ++ return writes.get(key) || ""; ++ }, ++ setItem(key, value) { ++ writes.set(key, value); ++ }, ++ }; ++ ++ assert.equal(writeSavedTextToSpeechProfiles([custom], storage), true); ++ const savedProfiles = readSavedTextToSpeechProfiles(storage); ++ const options = textToSpeechProfilesToMessageOptions(savedProfiles); + + assert.equal(TTS_PROFILE_CONTRACT_VERSION, "tts-profile-emotion-v1"); ++ assert.equal(writes.has(TEXT_TO_SPEECH_PROFILE_STORAGE_KEY), true); + assert.equal(defaults[0].name, "Default Balanced Profile"); + assert.equal(defaults[0].messageStudioUsageCount, 1); assert.deepEqual(defaults[0].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); assert.deepEqual(defaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); assert.deepEqual(defaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); -+ assert.deepEqual(messageStudioDefaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Calm", "Urgent"]); -+ assert.deepEqual(messageStudioDefaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Whisper", "Robot"]); +- assert.deepEqual(messageStudioDefaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Calm", "Urgent"]); +- assert.deepEqual(messageStudioDefaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Whisper", "Robot"]); assert.equal(defaults[0].emotions.find((emotion) => emotion.emotion === "neutral").messagePartsUsageCount, 1); assert.deepEqual(options, [{ active: true, -+ age: "any", -+ ageFilter: "any", + age: "any", + ageFilter: "any", emotionSettings: [{ ++ active: true, emotion: "urgent", emotionLabel: "Urgent", -@@ -103,10 +109,12 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt - ssmlLikePreset: "whisper-ish", - volume: 0.8, - }], -+ gender: "neutral", - key: "custom-profile", + key: "urgent", +@@ -114,6 +129,7 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt language: "en-US", name: "Custom Profile", providerKey: "browser-speech", -+ voice: "test-voice", ++ sourceProfileId: "custom-profile", + voice: "test-voice", voiceName: "Test Voice", }]); - }); diff --git a/toolbox/messages/messages.js b/toolbox/messages/messages.js -index 01d26e441..72c339f58 100644 +index 72c339f58..6eb31f6f1 100644 --- a/toolbox/messages/messages.js +++ b/toolbox/messages/messages.js -@@ -1,3 +1,7 @@ -+import { -+ createMessageStudioDefaultTtsProfiles, -+ createMessageStudioTtsProfileOptions, -+} from "../text-to-speech/text2speech.js"; +@@ -1,10 +1,12 @@ + import { +- createMessageStudioDefaultTtsProfiles, +- createMessageStudioTtsProfileOptions, +-} from "../text-to-speech/text2speech.js"; ++ readSavedTextToSpeechProfiles, ++ textToSpeechProfilesToMessageOptions, ++} from "../text-to-speech/tts-profile-store.js"; import { ++ createEmotionProfile, createMessage, createMessageSegment, -@@ -21,6 +25,11 @@ const TTS_PROVIDER_REGISTRY = Object.freeze({ ++ createTtsProfile, + deleteMessage, + deleteMessageSegment, + listEmotionProfiles, +@@ -25,11 +27,6 @@ const TTS_PROVIDER_REGISTRY = Object.freeze({ "openai": Object.freeze({ activeRuntime: false, label: "OpenAI", requiresConfig: true }), "polly": Object.freeze({ activeRuntime: false, label: "Polly", requiresConfig: true }), }); -+const MESSAGE_STUDIO_TTS_PROFILE_CONTRACT = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) -+ .map((profile) => Object.freeze({ -+ ...profile, -+ emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), -+ }))); +-const MESSAGE_STUDIO_TTS_PROFILE_CONTRACT = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) +- .map((profile) => Object.freeze({ +- ...profile, +- emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), +- }))); const elements = { addMessage: document.querySelector("[data-messages-add-row]"), -@@ -190,16 +199,55 @@ function showCreatorSafeFailure(message) { - setText(elements.log, safeMessage); +@@ -203,47 +200,99 @@ function normalizedLookupKey(value) { + return String(value || "").trim().toLowerCase(); } --function activeEmotionProfiles() { -- return state.emotionProfiles.filter((profile) => profile.active); -+function normalizedLookupKey(value) { -+ return String(value || "").trim().toLowerCase(); +-function apiTtsProfileByContractName(apiProfiles = []) { ++function apiTtsProfileByName(apiProfiles = []) { + return new Map(apiProfiles.map((profile) => [normalizedLookupKey(profile.name), profile])); } --function activeVoiceProfiles() { -- return state.voiceProfiles.filter((profile) => profile.active); -+function apiTtsProfileByContractName(apiProfiles = []) { +-function apiEmotionSettingForContract(apiProfile, contractSetting) { +- const settings = Array.isArray(apiProfile?.emotionSettings) ? apiProfile.emotionSettings : []; +- const label = normalizedLookupKey(contractSetting.emotionLabel || contractSetting.name || contractSetting.emotion); +- const emotion = normalizedLookupKey(contractSetting.emotion); +- return settings.find((setting) => normalizedLookupKey(setting.emotionLabel || setting.name) === label) +- || settings.find((setting) => normalizedLookupKey(setting.emotion) === emotion) +- || null; ++function apiEmotionProfileByName(apiProfiles = []) { + return new Map(apiProfiles.map((profile) => [normalizedLookupKey(profile.name), profile])); + } + +-function messageStudioTtsProfilesFromContract(apiProfiles = []) { +- const apiByName = apiTtsProfileByContractName(apiProfiles); +- return MESSAGE_STUDIO_TTS_PROFILE_CONTRACT.map((contractProfile) => { +- const apiProfile = apiByName.get(normalizedLookupKey(contractProfile.name)) || null; ++function ensureApiTtsProfileForSavedProfile(profile, apiByName) { ++ const name = profileValue(profile?.name, ""); ++ if (!name) { ++ return null; ++ } ++ const existing = apiByName.get(normalizedLookupKey(name)) || null; ++ if (existing) { ++ return existing; ++ } ++ const result = createTtsProfile({ ++ active: true, ++ description: `${name} from Text To Speech profile ownership.`, ++ language: profileValue(profile.language, "en-US"), ++ name, ++ pitch: 1, ++ providerKey: profileValue(profile.providerKey, "browser-speech"), ++ rate: 1, ++ voiceName: profileValue(profile.voiceName || profile.voice, "Default browser voice"), ++ volume: 1, ++ }); ++ const created = result.ttsProfile || null; ++ if (!created) { ++ throw new Error("Text To Speech profile could not be synced."); ++ } ++ apiByName.set(normalizedLookupKey(created.name), created); ++ return created; +} + -+function apiEmotionSettingForContract(apiProfile, contractSetting) { -+ const settings = Array.isArray(apiProfile?.emotionSettings) ? apiProfile.emotionSettings : []; -+ const label = normalizedLookupKey(contractSetting.emotionLabel || contractSetting.name || contractSetting.emotion); -+ const emotion = normalizedLookupKey(contractSetting.emotion); -+ return settings.find((setting) => normalizedLookupKey(setting.emotionLabel || setting.name) === label) -+ || settings.find((setting) => normalizedLookupKey(setting.emotion) === emotion) -+ || null; ++function ensureApiEmotionProfileForSavedEmotion(setting, apiByName) { ++ const name = profileValue(setting?.emotionLabel || setting?.name || setting?.emotion, ""); ++ if (!name) { ++ return null; ++ } ++ const existing = apiByName.get(normalizedLookupKey(name)) || null; ++ if (existing) { ++ return existing; ++ } ++ const result = createEmotionProfile({ ++ active: true, ++ description: `${name} from Text To Speech profile ownership.`, ++ name, ++ pauseAfterMs: 0, ++ pauseBeforeMs: 0, ++ pitch: Number(setting.pitch) || 1, ++ rate: Number(setting.rate) || 1, ++ volume: Number.isFinite(Number(setting.volume)) ? Number(setting.volume) : 1, ++ }); ++ const created = result.emotionProfile || null; ++ if (!created) { ++ throw new Error("Text To Speech emotion could not be synced."); ++ } ++ apiByName.set(normalizedLookupKey(created.name), created); ++ state.emotionProfiles.push(created); ++ return created; +} + -+function messageStudioTtsProfilesFromContract(apiProfiles = []) { -+ const apiByName = apiTtsProfileByContractName(apiProfiles); -+ return MESSAGE_STUDIO_TTS_PROFILE_CONTRACT.map((contractProfile) => { -+ const apiProfile = apiByName.get(normalizedLookupKey(contractProfile.name)) || null; -+ return { -+ ...contractProfile, -+ active: apiProfile ? apiProfile.active !== false : contractProfile.active !== false, -+ age: contractProfile.age || apiProfile?.age || "", -+ ageFilter: contractProfile.ageFilter || apiProfile?.ageFilter || apiProfile?.age || "", -+ emotionSettings: contractProfile.emotionSettings -+ .map((contractSetting) => { -+ const apiSetting = apiEmotionSettingForContract(apiProfile, contractSetting); -+ const emotionProfile = emotionProfileByLabel(contractSetting.emotionLabel || apiSetting?.emotionLabel || contractSetting.emotion); -+ return { -+ ...contractSetting, -+ active: apiSetting ? apiSetting.active !== false : contractSetting.active !== false, -+ key: emotionProfile?.key || apiSetting?.key || contractSetting.key, -+ }; -+ }) -+ .filter((setting) => setting.key && setting.active !== false), -+ gender: contractProfile.gender || apiProfile?.gender || "", -+ key: apiProfile?.key || contractProfile.key, -+ language: contractProfile.language || apiProfile?.language || "", -+ providerKey: contractProfile.providerKey || apiProfile?.providerKey || "browser-speech", -+ voice: contractProfile.voice || apiProfile?.voice || apiProfile?.voiceName || "", -+ voiceName: contractProfile.voiceName || apiProfile?.voiceName || "", -+ }; -+ }); ++function activeTextToSpeechProfilesForMessages(apiProfiles = []) { ++ const savedProfiles = readSavedTextToSpeechProfiles(); ++ const savedOptions = textToSpeechProfilesToMessageOptions(savedProfiles); ++ const ttsProfilesByName = apiTtsProfileByName(apiProfiles); ++ const emotionProfilesByName = apiEmotionProfileByName(state.emotionProfiles); ++ return savedOptions.map((savedProfile) => { ++ const apiProfile = ensureApiTtsProfileForSavedProfile(savedProfile, ttsProfilesByName); ++ if (!apiProfile) { ++ return null; ++ } + return { +- ...contractProfile, +- active: apiProfile ? apiProfile.active !== false : contractProfile.active !== false, +- age: contractProfile.age || apiProfile?.age || "", +- ageFilter: contractProfile.ageFilter || apiProfile?.ageFilter || apiProfile?.age || "", +- emotionSettings: contractProfile.emotionSettings +- .map((contractSetting) => { +- const apiSetting = apiEmotionSettingForContract(apiProfile, contractSetting); +- const emotionProfile = emotionProfileByLabel(contractSetting.emotionLabel || apiSetting?.emotionLabel || contractSetting.emotion); ++ ...savedProfile, ++ active: apiProfile.active !== false && savedProfile.active !== false, ++ key: apiProfile.key, ++ emotionSettings: savedProfile.emotionSettings ++ .map((setting) => { ++ const emotionProfile = ensureApiEmotionProfileForSavedEmotion(setting, emotionProfilesByName); ++ if (!emotionProfile) { ++ return null; ++ } + return { +- ...contractSetting, +- active: apiSetting ? apiSetting.active !== false : contractSetting.active !== false, +- key: emotionProfile?.key || apiSetting?.key || contractSetting.key, ++ ...setting, ++ active: emotionProfile.active !== false && setting.active !== false, ++ key: emotionProfile.key, + }; + }) +- .filter((setting) => setting.key && setting.active !== false), +- gender: contractProfile.gender || apiProfile?.gender || "", +- key: apiProfile?.key || contractProfile.key, +- language: contractProfile.language || apiProfile?.language || "", +- providerKey: contractProfile.providerKey || apiProfile?.providerKey || "browser-speech", +- voice: contractProfile.voice || apiProfile?.voice || apiProfile?.voiceName || "", +- voiceName: contractProfile.voiceName || apiProfile?.voiceName || "", ++ .filter((setting) => setting?.key && setting.active !== false), + }; +- }); ++ }).filter((profile) => profile?.key); } --function emotionProfileByKey(profileKey) { -- return state.emotionProfiles.find((profile) => profile.key === profileKey) || null; -+function activeVoiceProfiles() { -+ return state.voiceProfiles.filter((profile) => profile.active); - } - - function voiceProfileByKey(profileKey) { -@@ -210,15 +258,6 @@ function ttsProfileByKey(profileKey) { - return voiceProfileByKey(profileKey); - } - --function selectOptionsWithCurrent(currentKey) { -- const active = activeEmotionProfiles(); -- const current = emotionProfileByKey(currentKey); -- if (current && !active.some((profile) => profile.key === current.key)) { -- return [...active, current]; -- } -- return active; --} -- - function voiceOptionsWithCurrent(currentKey) { - const active = activeVoiceProfiles(); - const current = voiceProfileByKey(currentKey); -@@ -228,23 +267,16 @@ function voiceOptionsWithCurrent(currentKey) { - return active; - } - --function emotionOptionsForTtsProfile(profileKey, currentKey = "") { -+function emotionOptionsForTtsProfile(profileKey) { - const profile = ttsProfileByKey(profileKey); - const settings = Array.isArray(profile?.emotionSettings) ? profile.emotionSettings : []; -- const options = settings -+ return settings - .filter((setting) => setting.active !== false) - .map((setting) => ({ - key: setting.key || emotionProfileByLabel(setting.emotionLabel || setting.emotion)?.key || setting.emotion, - name: setting.emotionLabel || setting.name || setting.emotion, - })) - .filter((setting) => setting.key && setting.name); -- if (currentKey && !options.some((option) => option.key === currentKey)) { -- const current = emotionProfileByKey(currentKey); -- if (current) { -- options.push({ key: current.key, name: current.name }); -- } -- } -- return options.length ? options : selectOptionsWithCurrent(currentKey); - } - - function emotionProfileByLabel(label) { -@@ -263,8 +295,9 @@ function emotionSettingForKey(profileKey, emotionKey) { - || null; - } - --function defaultEmotionKeyForTtsProfile(profileKey) { -- return emotionOptionsForTtsProfile(profileKey)[0]?.key || ""; -+function profileEmotionKeyOrDefault(profileKey, currentKey = "") { -+ const options = emotionOptionsForTtsProfile(profileKey); -+ return options.some((option) => option.key === currentKey) ? currentKey : options[0]?.key || ""; - } - - function selectedMessage() { -@@ -716,7 +749,7 @@ function createMessageSegmentEditRow(messageKey, segment = null) { - emotionCell.append(createSelect( - segment?.emotionProfileKey || "", - "segmentEmotion", -- emotionOptionsForTtsProfile(message?.voiceProfileKey || segment?.voiceProfileKey || "", segment?.emotionProfileKey || ""), -+ emotionOptionsForTtsProfile(message?.voiceProfileKey || segment?.voiceProfileKey || ""), - "Select emotion", - "Sentence emotion", - )); -@@ -911,9 +944,10 @@ function messageValues(key) { - const existing = state.messages.find((message) => message.key === key) || null; - const name = editorValue(root, "[data-message-name]"); - const voiceProfileKey = editorValue(root, "[data-message-tts-profile]"); -+ const emotionProfileKey = profileEmotionKeyOrDefault(voiceProfileKey, existing?.emotionProfileKey || ""); - return { - active: existing ? existing.active : true, -- emotionProfileKey: existing?.emotionProfileKey || defaultEmotionKeyForTtsProfile(voiceProfileKey), -+ emotionProfileKey, - messageText: existing?.messageText || name, - name, - notes: existing?.notes || "", -@@ -994,7 +1028,7 @@ async function loadAll() { + function activeVoiceProfiles() { +@@ -1028,7 +1077,12 @@ async function loadAll() { state.emotionProfiles = emotionPayload.emotionProfiles || []; state.messages = messagesPayload.messages || []; state.segments = segmentsPayload.segments || []; -- state.voiceProfiles = voicePayload.ttsProfiles || []; -+ state.voiceProfiles = messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || []); +- state.voiceProfiles = messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || []); ++ try { ++ state.voiceProfiles = activeTextToSpeechProfilesForMessages(voicePayload.ttsProfiles || []); ++ } catch { ++ state.voiceProfiles = []; ++ showCreatorSafeFailure("Text To Speech profiles could not be loaded. Save a TTS Profile in Text To Speech, then reload Messages."); ++ } if (state.selectedMessageKey && !state.messages.some((message) => message.key === state.selectedMessageKey)) { state.selectedMessageKey = ""; } +@@ -1036,6 +1090,10 @@ async function loadAll() { + state.selectedSegmentKey = ""; + } + render(messagesPayload.persistence || emotionPayload.persistence || segmentsPayload.persistence || voicePayload.persistence); ++ if (!state.voiceProfiles.length) { ++ setText(elements.log, "Message Studio loaded. Save a TTS Profile in Text To Speech before adding playback."); ++ return; ++ } + setText(elements.log, "Message Studio loaded."); + } + diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js -index c44c1d406..d98085336 100644 +index d98085336..081fa25b2 100644 --- a/toolbox/text-to-speech/text2speech.js +++ b/toolbox/text-to-speech/text2speech.js -@@ -88,7 +88,8 @@ const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ - Object.freeze({ label: "Calm", value: "calm" }), - Object.freeze({ label: "Urgent", value: "urgent" }), - Object.freeze({ label: "Whisper", value: "whisper" }), -- Object.freeze({ label: "Excited", value: "excited" }) -+ Object.freeze({ label: "Excited", value: "excited" }), -+ Object.freeze({ label: "Robot", value: "robot" }) - ]); +@@ -10,6 +10,10 @@ import { + TEXT_TO_SPEECH_RANGE_DEFAULTS, + TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS + } from "../../src/engine/audio/TextToSpeechDefaults.js"; ++import { ++ readSavedTextToSpeechProfiles, ++ writeSavedTextToSpeechProfiles, ++} from "./tts-profile-store.js"; - function boundedNumber(value, { fallback, max, min, value: defaultValue }) { -@@ -279,11 +280,45 @@ function createDefaultTextToSpeechProfiles(voiceOptions = []) { + const TTS_OWNERSHIP = Object.freeze({ + DESIGN: "Design", +@@ -280,66 +284,6 @@ function createDefaultTextToSpeechProfiles(voiceOptions = []) { ]; } -+function createMessageStudioDefaultTtsProfiles(voiceOptions = []) { -+ const [balancedProfile, manProfile, womanProfile] = createDefaultTextToSpeechProfiles(voiceOptions); -+ const withStudioEmotions = (profile, emotions) => createTextToSpeechProfile({ -+ active: profile.active, -+ age: profile.age, +-function createMessageStudioDefaultTtsProfiles(voiceOptions = []) { +- const [balancedProfile, manProfile, womanProfile] = createDefaultTextToSpeechProfiles(voiceOptions); +- const withStudioEmotions = (profile, emotions) => createTextToSpeechProfile({ +- active: profile.active, +- age: profile.age, +- emotions, +- gender: profile.gender, +- id: profile.id, +- language: profile.language, +- messageStudioUsageCount: profile.messageStudioUsageCount, +- name: profile.name, +- voice: profile.voice, +- voiceName: profile.voiceName +- }); +- +- return [ +- withStudioEmotions(balancedProfile, [ +- createTextToSpeechProfileEmotion({ emotion: "calm", messagePartsUsageCount: 1 }), +- createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), +- ]), +- withStudioEmotions(manProfile, [ +- createTextToSpeechProfileEmotion({ emotion: "neutral" }), +- createTextToSpeechProfileEmotion({ emotion: "calm" }), +- createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), +- ]), +- withStudioEmotions(womanProfile, [ +- createTextToSpeechProfileEmotion({ emotion: "whisper", pitch: 0.95, rate: 0.9, volume: 0.55 }), +- createTextToSpeechProfileEmotion({ emotion: "robot", pitch: 0.82, rate: 0.92, volume: 0.9 }), +- ]) +- ]; +-} +- +-function createMessageStudioTtsProfileOptions(profiles = []) { +- return profiles +- .filter((profile) => profile?.active !== false) +- .map((profile) => ({ +- active: true, +- age: profile.age, +- ageFilter: profile.age, +- emotionSettings: Array.isArray(profile.emotions) +- ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ +- emotion: emotion.emotion, +- emotionLabel: emotion.emotionLabel, +- key: emotion.id, +- pitch: emotion.pitch, +- rate: emotion.rate, +- ssmlLikePreset: emotion.ssmlLikePreset, +- volume: emotion.volume +- })) +- : [], +- gender: profile.gender, +- key: profile.id, +- language: profile.language, +- name: profile.name, +- providerKey: profile.providerKey || "browser-speech", +- voice: profile.voice, +- voiceName: profile.voiceName || profile.voice || "" +- })); +-} +- + function createSpeechPreviewRequest({ + pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, + rate = TEXT_TO_SPEECH_DEFAULTS.rate, +@@ -817,6 +761,14 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + return errors; + } + ++ function persistProfilesForMessages() { ++ try { ++ writeSavedTextToSpeechProfiles(state.profiles); ++ } catch { ++ writeStatus("Text To Speech profiles were saved for this tool but could not be shared with Messages. Try again after refreshing the browser.", "FAIL"); ++ } ++ } ++ + function emotionValues(key) { + const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); + const existing = selectedProfile()?.emotions.find((emotion) => emotion.id === key) || null; +@@ -874,6 +826,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + state.selectedProfileId = profile.id; + state.selectedEmotionId = previewEmotion(profile)?.id || ""; + state.editingProfileId = ""; ++ persistProfilesForMessages(); + renderProfileRows(); + refreshActionState(); + writeStatus(`Saved TTS profile: ${profile.name}.`); +@@ -889,6 +842,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + state.profiles = state.profiles.filter((candidate) => candidate.id !== key); + if (state.selectedProfileId === key) state.selectedProfileId = state.profiles[0]?.id || ""; + if (!state.selectedProfileId) state.selectedEmotionId = ""; ++ persistProfilesForMessages(); + renderProfileRows(); + refreshActionState(); + writeStatus(`Deleted TTS profile: ${profile.name}.`); +@@ -923,6 +877,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + } + state.editingEmotionId = ""; + state.selectedEmotionId = emotion.id; ++ persistProfilesForMessages(); + renderProfileRows(); + refreshActionState(); + writeStatus(`Saved emotion: ${emotion.emotionLabel}.`); +@@ -938,6 +893,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + } + profile.emotions = profile.emotions.filter((candidate) => candidate.id !== key); + if (state.selectedEmotionId === key) state.selectedEmotionId = previewEmotion(profile)?.id || ""; ++ persistProfilesForMessages(); + renderProfileRows(); + refreshActionState(); + writeStatus(`Deleted emotion: ${emotion.emotionLabel}.`); +@@ -970,9 +926,23 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + if (state.profiles.length) { + return; + } ++ try { ++ const savedProfiles = readSavedTextToSpeechProfiles(); ++ if (savedProfiles.length) { ++ state.profiles = savedProfiles; ++ state.selectedProfileId = ""; ++ state.selectedEmotionId = ""; ++ renderProfileRows(); ++ refreshActionState(); ++ return; ++ } ++ } catch { ++ writeStatus("Saved Text To Speech profiles could not be loaded. Default profiles are available for this session.", "FAIL"); ++ } + state.profiles = createDefaultTextToSpeechProfiles(state.voiceOptions); + state.selectedProfileId = ""; + state.selectedEmotionId = ""; ++ persistProfilesForMessages(); + renderProfileRows(); + refreshActionState(); + } +@@ -1255,8 +1225,6 @@ export { + TTS_PROVIDER_ADAPTER_PLAN, + createEmotionProfile, + createDefaultTextToSpeechProfiles, +- createMessageStudioDefaultTtsProfiles, +- createMessageStudioTtsProfileOptions, + createSpeechPreviewRequest, + createTextToSpeechProfile, + createTextToSpeechProfileEmotion, + +diff --git a/toolbox/text-to-speech/tts-profile-store.js b/toolbox/text-to-speech/tts-profile-store.js +new file mode 100644 +index 000000000..268854662 +--- /dev/null ++++ b/toolbox/text-to-speech/tts-profile-store.js +@@ -0,0 +1,160 @@ ++const TEXT_TO_SPEECH_PROFILE_STORAGE_KEY = "gamefoundry.textToSpeech.profiles.v1"; ++const TEXT_TO_SPEECH_PROFILE_STORE_VERSION = "tts-profile-store-v1"; ++ ++const DEFAULT_LANGUAGE = "en-US"; ++const DEFAULT_PROVIDER_KEY = "browser-speech"; ++const DEFAULT_VOICE_AGE = "adult"; ++ ++function clampNumber(value, fallback, min, max) { ++ const numberValue = Number(value); ++ if (!Number.isFinite(numberValue)) { ++ return fallback; ++ } ++ return Math.min(max, Math.max(min, numberValue)); ++} ++ ++function normalizedText(value, fallback = "") { ++ const text = String(value || "").trim(); ++ return text || fallback; ++} ++ ++function slugFromText(value, fallback = "item") { ++ return normalizedText(value, fallback) ++ .toLowerCase() ++ .replace(/[^a-z0-9]+/g, "-") ++ .replace(/^-+|-+$/g, "") || fallback; ++} ++ ++function labelFromSlug(value, fallback = "Neutral") { ++ return normalizedText(value, fallback) ++ .replace(/[-_]+/g, " ") ++ .replace(/\b\w/g, (letter) => letter.toUpperCase()); ++} ++ ++function defaultStorage() { ++ try { ++ return typeof window === "undefined" ? null : window.localStorage; ++ } catch { ++ return null; ++ } ++} ++ ++function storagePayloadProfiles(payload) { ++ if (Array.isArray(payload)) { ++ return payload; ++ } ++ if (Array.isArray(payload?.profiles)) { ++ return payload.profiles; ++ } ++ return []; ++} ++ ++function normalizeSavedEmotion(emotion = {}) { ++ const emotionKey = slugFromText(emotion.emotion || emotion.id || emotion.emotionLabel, "neutral"); ++ const emotionLabel = normalizedText(emotion.emotionLabel || emotion.name, labelFromSlug(emotionKey)); ++ return { ++ active: emotion.active !== false, ++ emotion: emotionKey, ++ emotionLabel, ++ id: normalizedText(emotion.id, emotionKey), ++ messagePartsUsageCount: Math.max(0, Number(emotion.messagePartsUsageCount) || 0), ++ pitch: clampNumber(emotion.pitch, 1, 0.1, 2), ++ rate: clampNumber(emotion.rate, 1, 0.1, 2), ++ ssmlLikePreset: normalizedText(emotion.ssmlLikePreset, "normal"), ++ volume: clampNumber(emotion.volume, 1, 0, 1), ++ }; ++} ++ ++function normalizeSavedProfile(profile = {}) { ++ const name = normalizedText(profile.name, "Default Balanced Profile"); ++ const emotions = Array.isArray(profile.emotions) && profile.emotions.length ++ ? profile.emotions.map(normalizeSavedEmotion) ++ : [normalizeSavedEmotion()]; ++ return { ++ active: profile.active !== false, ++ age: normalizedText(profile.age, DEFAULT_VOICE_AGE), + emotions, -+ gender: profile.gender, -+ id: profile.id, -+ language: profile.language, -+ messageStudioUsageCount: profile.messageStudioUsageCount, -+ name: profile.name, -+ voice: profile.voice, -+ voiceName: profile.voiceName -+ }); ++ gender: normalizedText(profile.gender, "neutral"), ++ id: normalizedText(profile.id, slugFromText(name, "tts-profile")), ++ language: normalizedText(profile.language, DEFAULT_LANGUAGE), ++ messageStudioUsageCount: Math.max(0, Number(profile.messageStudioUsageCount) || 0), ++ name, ++ owner: "Audio", ++ providerKey: normalizedText(profile.providerKey, DEFAULT_PROVIDER_KEY), ++ voice: normalizedText(profile.voice), ++ voiceName: normalizedText(profile.voiceName || profile.voice, "Default browser voice"), ++ }; ++} ++ ++function normalizeSavedTextToSpeechProfiles(profiles = []) { ++ return Array.isArray(profiles) ? profiles.map(normalizeSavedProfile) : []; ++} + -+ return [ -+ withStudioEmotions(balancedProfile, [ -+ createTextToSpeechProfileEmotion({ emotion: "calm", messagePartsUsageCount: 1 }), -+ createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), -+ ]), -+ withStudioEmotions(manProfile, [ -+ createTextToSpeechProfileEmotion({ emotion: "neutral" }), -+ createTextToSpeechProfileEmotion({ emotion: "calm" }), -+ createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), -+ ]), -+ withStudioEmotions(womanProfile, [ -+ createTextToSpeechProfileEmotion({ emotion: "whisper", pitch: 0.95, rate: 0.9, volume: 0.55 }), -+ createTextToSpeechProfileEmotion({ emotion: "robot", pitch: 0.82, rate: 0.92, volume: 0.9 }), -+ ]) -+ ]; ++function readSavedTextToSpeechProfiles(storage = defaultStorage()) { ++ if (!storage || typeof storage.getItem !== "function") { ++ return []; ++ } ++ const raw = storage.getItem(TEXT_TO_SPEECH_PROFILE_STORAGE_KEY); ++ if (!raw) { ++ return []; ++ } ++ let payload; ++ try { ++ payload = JSON.parse(raw); ++ } catch { ++ throw new Error("Saved Text To Speech profiles could not be read."); ++ } ++ return normalizeSavedTextToSpeechProfiles(storagePayloadProfiles(payload)); +} + - function createMessageStudioTtsProfileOptions(profiles = []) { - return profiles - .filter((profile) => profile?.active !== false) - .map((profile) => ({ - active: true, ++function writeSavedTextToSpeechProfiles(profiles = [], storage = defaultStorage()) { ++ if (!storage || typeof storage.setItem !== "function") { ++ return false; ++ } ++ const payload = { ++ profiles: normalizeSavedTextToSpeechProfiles(profiles), ++ updatedAt: new Date().toISOString(), ++ version: TEXT_TO_SPEECH_PROFILE_STORE_VERSION, ++ }; ++ storage.setItem(TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, JSON.stringify(payload)); ++ return true; ++} ++ ++function textToSpeechProfilesToMessageOptions(profiles = []) { ++ return normalizeSavedTextToSpeechProfiles(profiles) ++ .filter((profile) => profile.active !== false) ++ .map((profile) => ({ ++ active: true, + age: profile.age, + ageFilter: profile.age, - emotionSettings: Array.isArray(profile.emotions) - ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ - emotion: emotion.emotion, -@@ -295,10 +330,12 @@ function createMessageStudioTtsProfileOptions(profiles = []) { - volume: emotion.volume - })) - : [], ++ emotionSettings: profile.emotions ++ .filter((emotion) => emotion.active !== false) ++ .map((emotion) => ({ ++ active: true, ++ emotion: emotion.emotion, ++ emotionLabel: emotion.emotionLabel, ++ key: emotion.id, ++ pitch: emotion.pitch, ++ rate: emotion.rate, ++ ssmlLikePreset: emotion.ssmlLikePreset, ++ volume: emotion.volume, ++ })), + gender: profile.gender, - key: profile.id, - language: profile.language, - name: profile.name, - providerKey: profile.providerKey || "browser-speech", ++ key: profile.id, ++ language: profile.language, ++ name: profile.name, ++ providerKey: profile.providerKey, ++ sourceProfileId: profile.id, + voice: profile.voice, - voiceName: profile.voiceName || profile.voice || "" - })); - } -@@ -1206,7 +1243,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech - }; - } - --if (typeof document !== "undefined") { -+if (typeof document !== "undefined" && document.querySelector("[data-tts-profile-table]")) { - initializeTextToSpeechTool(document); - } - -@@ -1218,6 +1255,7 @@ export { - TTS_PROVIDER_ADAPTER_PLAN, - createEmotionProfile, - createDefaultTextToSpeechProfiles, -+ createMessageStudioDefaultTtsProfiles, - createMessageStudioTtsProfileOptions, - createSpeechPreviewRequest, - createTextToSpeechProfile, ++ voiceName: profile.voiceName || profile.voice || "Default browser voice", ++ })); ++} ++ ++export { ++ TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, ++ TEXT_TO_SPEECH_PROFILE_STORE_VERSION, ++ normalizeSavedTextToSpeechProfiles, ++ readSavedTextToSpeechProfiles, ++ textToSpeechProfilesToMessageOptions, ++ writeSavedTextToSpeechProfiles, ++}; + + +diff --git a/docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md b/docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md +new file mode 100644 +index 000000000..69904e644 +--- /dev/null ++++ b/docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md +@@ -0,0 +1,51 @@ ++# PR_26174_BRAVO_022-use-active-tts-profiles-in-messages ++ ++## Branch Validation ++PASS - Current branch is `team/BRAVO/messages`; main was not checked out, merged, or targeted. ++ ++## Summary ++- Added a shared Text To Speech profile store/contract helper for active saved TTS profiles. ++- Text To Speech now persists its own profile/emotion model for sibling tools. ++- Messages now loads saved TTS profiles, syncs profile/emotion names to Local API-owned keys, filters Sentence emotions by selected parent profile, and keeps playback off Text To Speech Local Preview validation. ++- Removed the dev-runtime import of the browser Text To Speech UI module and removed separate Message Studio default TTS profile builders. ++ ++## Requirement Checklist ++PASS - /toolbox/messages/ loads active saved TTS Profiles from /toolbox/text-to-speech/ via `tts-profile-store.js`. ++PASS - Parent Message TTS Profile dropdown is populated from saved active TTS profiles. ++PASS - Child Sentence Emotion dropdown is populated only from the selected parent profile's saved emotions. ++PASS - Parent Play remains in Message row Actions and resolves ordered Sentences through the selected parent profile. ++PASS - Child Play remains in Sentence row Actions and resolves only that Sentence through the parent profile. ++PASS - Messages playback does not call Text To Speech Local Preview validation and `toolbox/messages/messages.js` contains no `before preview` text. ++PASS - Browser UI module imports were removed from `src/dev-runtime/messages/messages-postgres-service.mjs`. ++PASS - Separate Message Studio default TTS profile builders were removed. ++PASS - No inline styles, inline scripts, style blocks, or inline handlers were added. ++PASS - Local API owns authoritative database keys; Messages syncs saved names to server-created TTS/emotion keys. ++PASS - Creator-safe guidance is used for Messages TTS profile load failures. ++ ++## Validation Lane Report ++PASS - `node --check toolbox/messages/messages.js`. ++PASS - `node --check toolbox/text-to-speech/text2speech.js`. ++PASS - `node --check toolbox/text-to-speech/tts-profile-store.js`. ++PASS - `node --check src/dev-runtime/messages/messages-postgres-service.mjs`. ++PASS - `node --test tests/tools/Text2SpeechShell.test.mjs`. ++PASS - `node --test tests/tools/MessagesPlaybackSource.test.mjs`. ++PASS - `node --test tests/dev-runtime/MessagesPublishValidation.test.mjs`. ++PASS - `node --test --test-name-pattern "Messages Local API" tests/dev-runtime/DbSeedIntegrity.test.mjs`. ++BLOCKED - `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs tests/playwright/tools/TextToSpeechFunctional.spec.mjs` could not launch because Chromium is not installed at `C:\Users\davidq\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe`. ++BLOCKED - Fallback `npm run test:workspace-v2` completed static preflight report generation, then hit the same missing Chromium executable. ++KNOWN - Full `tests/dev-runtime/DbSeedIntegrity.test.mjs` still has unrelated Local DB snapshot failures in non-Messages cases. ++ ++## Manual Validation Notes ++- Browser manual validation was not completed because Playwright Chromium is missing in this environment. ++- Added Playwright coverage for creating/editing a TTS Profile in Text To Speech, opening Messages on the same origin, confirming the saved profile appears, confirming Sentence emotions are profile-filtered, and exercising Parent/Child Play. ++- Static grep confirmed Messages no longer contains `Select an available browser voice before preview` or `before preview` wording. ++ ++## Branch Status ++- Branch: `team/BRAVO/messages` ++- Latest commit: `c45328678` ++- Ahead of main: 2 ++- Behind main: 0 ++- Existing unrelated local edit excluded from PR package: `.gitignore`. ++ ++## ZIP ++- Expected ZIP: `tmp/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages_delta.zip` + + +diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt +new file mode 100644 +index 000000000..899ecf86c +--- /dev/null ++++ b/docs_build/dev/reports/codex_changed_files.txt +@@ -0,0 +1,42 @@ ++docs_build/dev/reports/PR_26174_BRAVO_022-use-active-tts-profiles-in-messages.md ++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/dependency_gating_report.md ++docs_build/dev/reports/dependency_hydration_reuse_report.md ++docs_build/dev/reports/execution_graph_reuse_report.md ++docs_build/dev/reports/failure_fingerprint_report.md ++docs_build/dev/reports/filesystem_scan_reduction_report.md ++docs_build/dev/reports/incremental_validation_report.md ++docs_build/dev/reports/lane_compilation_report.md ++docs_build/dev/reports/lane_deduplication_report.md ++docs_build/dev/reports/lane_input_validation_report.md ++docs_build/dev/reports/lane_manifests/workspace-contract.json ++docs_build/dev/reports/lane_runtime_optimization_report.md ++docs_build/dev/reports/lane_snapshot_report.md ++docs_build/dev/reports/lane_snapshots/workspace-contract.json ++docs_build/dev/reports/lane_warm_start_report.md ++docs_build/dev/reports/lane_warm_starts/workspace-contract.json ++docs_build/dev/reports/monolith_trigger_removal_report.md ++docs_build/dev/reports/persistent_lane_manifest_report.md ++docs_build/dev/reports/playwright_discovery_ownership_report.md ++docs_build/dev/reports/playwright_discovery_scope_report.md ++docs_build/dev/reports/playwright_structure_audit.md ++docs_build/dev/reports/playwright_v8_coverage_report.txt ++docs_build/dev/reports/retry_suppression_report.md ++docs_build/dev/reports/slow_path_pruning_report.md ++docs_build/dev/reports/static_validation_report.md ++docs_build/dev/reports/targeted_file_manifest_report.md ++docs_build/dev/reports/test_cleanup_performance_report.md ++docs_build/dev/reports/test_cleanup_routing_report.md ++docs_build/dev/reports/testing_lane_execution_report.md ++docs_build/dev/reports/validation_cache_report.md ++docs_build/dev/reports/zero_browser_preflight_report.md ++src/dev-runtime/messages/messages-postgres-service.mjs ++tests/dev-runtime/DbSeedIntegrity.test.mjs ++tests/playwright/tools/MessagesTool.spec.mjs ++tests/tools/MessagesPlaybackSource.test.mjs ++tests/tools/Text2SpeechShell.test.mjs ++toolbox/messages/messages.js ++toolbox/text-to-speech/text2speech.js ++toolbox/text-to-speech/tts-profile-store.js + diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt index 74a29674c..83a4ad849 100644 --- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt +++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt @@ -6,7 +6,13 @@ Missing changed runtime JS files are WARN, not FAIL. Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. Changed runtime JS files considered: -(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only Guardrail warnings: -(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: changed runtime JS file missing from coverage; advisory only diff --git a/docs_build/dev/reports/dependency_gating_report.md b/docs_build/dev/reports/dependency_gating_report.md index 319848da9..10f49ad99 100644 --- a/docs_build/dev/reports/dependency_gating_report.md +++ b/docs_build/dev/reports/dependency_gating_report.md @@ -1,6 +1,6 @@ # Dependency Gating Report -Generated: 2026-06-21T00:10:10.215Z +Generated: 2026-06-23T16:38:48.295Z Status: PASS ## Gate Order @@ -15,7 +15,7 @@ Status: PASS | Lane | Selected | Status | Dependencies | Affected Surface | Reason | | --- | --- | --- | --- | --- | --- | | workspace-contract | Yes | PASS | none | Root tools future-state navigation and Tool Template V2 contract | Lane has no lane dependencies and is eligible after preflight and compilation pass. | -| game-workspace | No | SKIP | none | Game Workspace mock repository, Game Workspace UI, and Toolbox Progress/Build Path game-state bridge | Lane was not selected, so dependency-gated runtime scheduling skipped it. | +| game-hub | No | SKIP | none | Game Hub mock repository, Game Hub UI, and Toolbox Progress/Build Path game-state bridge | Lane was not selected, so dependency-gated runtime scheduling skipped it. | | game-design | No | SKIP | none | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | Lane was not selected, so dependency-gated runtime scheduling skipped it. | | game-configuration | No | SKIP | none | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | Lane was not selected, so dependency-gated runtime scheduling skipped it. | | asset-tool | No | SKIP | none | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | Lane was not selected, so dependency-gated runtime scheduling skipped it. | diff --git a/docs_build/dev/reports/dependency_hydration_reuse_report.md b/docs_build/dev/reports/dependency_hydration_reuse_report.md index 0f8c874e3..4546b7441 100644 --- a/docs_build/dev/reports/dependency_hydration_reuse_report.md +++ b/docs_build/dev/reports/dependency_hydration_reuse_report.md @@ -1,6 +1,6 @@ # Dependency Hydration Reuse Report -Generated: 2026-06-21T00:10:10.216Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS ## Summary @@ -16,7 +16,7 @@ Prevented fixture ownership traversal: 0 | Lane | Status | Helpers | Fixtures | Imports | Dependency Hydration Hash | Reason | | --- | --- | --- | --- | --- | --- | --- | -| workspace-contract | INVALIDATED | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | 355ba7a85dbb3cdb | Dependency hydration was refreshed after warm-start invalidation. | +| workspace-contract | INVALIDATED | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | e9956a75c3585e86 | Dependency hydration was refreshed after warm-start invalidation. | ## Safeguards diff --git a/docs_build/dev/reports/execution_graph_reuse_report.md b/docs_build/dev/reports/execution_graph_reuse_report.md index ddbdf90a1..b875bdac2 100644 --- a/docs_build/dev/reports/execution_graph_reuse_report.md +++ b/docs_build/dev/reports/execution_graph_reuse_report.md @@ -1,6 +1,6 @@ # Execution Graph Reuse Report -Generated: 2026-06-21T00:10:10.216Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS ## Summary @@ -16,7 +16,7 @@ Prevented targeted scheduling work: 0 | Lane | Status | Snapshot Status | Execution Graph Hash | Reason | | --- | --- | --- | --- | --- | -| workspace-contract | INVALIDATED | INVALIDATED | 51dc9d019b71a923 | Lane snapshot is part of the selected targeted execution graph. | +| workspace-contract | INVALIDATED | INVALIDATED | 98ec1cea6aaaef57 | Lane snapshot is part of the selected targeted execution graph. | ## Safeguards diff --git a/docs_build/dev/reports/failure_fingerprint_report.md b/docs_build/dev/reports/failure_fingerprint_report.md index c9822fe4a..a1d3f0de6 100644 --- a/docs_build/dev/reports/failure_fingerprint_report.md +++ b/docs_build/dev/reports/failure_fingerprint_report.md @@ -1,12 +1,12 @@ # Failure Fingerprint Report -Generated: 2026-06-21T00:11:18.106Z -Status: PASS +Generated: 2026-06-23T16:38:57.091Z +Status: WARN ## Summary Deterministic setup failures: 0 -Runtime failures: 0 +Runtime failures: 1 Flaky/transient failures: 0 Infrastructure failures: 0 @@ -14,7 +14,7 @@ Infrastructure failures: 0 | Fingerprint | Category | Rule | Lane | Source | Retry Allowed | Diagnostic | | --- | --- | --- | --- | --- | --- | --- | -| none | none | none | none | none | No | No failures observed during deterministic classification. | +| fc8ad6ba552baa70 | runtime failure | runtime-failure | workspace-contract | runtime command | Yes | workspace-contract command failed: "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | ## Known Deterministic Fingerprint Rules diff --git a/docs_build/dev/reports/filesystem_scan_reduction_report.md b/docs_build/dev/reports/filesystem_scan_reduction_report.md index 3fc7accce..062fbbe26 100644 --- a/docs_build/dev/reports/filesystem_scan_reduction_report.md +++ b/docs_build/dev/reports/filesystem_scan_reduction_report.md @@ -1,6 +1,6 @@ # Filesystem Scan Reduction Report -Generated: 2026-06-21T00:10:10.181Z +Generated: 2026-06-23T16:38:48.280Z Status: PASS ## Scan Enforcement diff --git a/docs_build/dev/reports/incremental_validation_report.md b/docs_build/dev/reports/incremental_validation_report.md index d4aa9a02b..43aa18265 100644 --- a/docs_build/dev/reports/incremental_validation_report.md +++ b/docs_build/dev/reports/incremental_validation_report.md @@ -1,6 +1,6 @@ # Incremental Validation Report -Generated: 2026-06-21T00:10:10.218Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS ## Reuse Summary @@ -18,7 +18,7 @@ Prevented fixture resolution passes: 0 | Lane | Decision | Invalidated By | Runtime Savings Observation | | --- | --- | --- | --- | -| workspace-contract | INVALIDATED | Persistent manifest input hash changed for workspace-contract.; Persistent manifest hash changed for workspace-contract. | Manifest was regenerated or skipped; no reuse savings for this lane. | +| workspace-contract | INVALIDATED | Persistent manifest lane definition hash changed for workspace-contract. | Manifest was regenerated or skipped; no reuse savings for this lane. | ## Invalidation Rules diff --git a/docs_build/dev/reports/lane_compilation_report.md b/docs_build/dev/reports/lane_compilation_report.md index 1b65b72a4..c0a7442d4 100644 --- a/docs_build/dev/reports/lane_compilation_report.md +++ b/docs_build/dev/reports/lane_compilation_report.md @@ -1,26 +1,26 @@ # Lane Compilation Report -Generated: 2026-06-21T00:10:10.215Z +Generated: 2026-06-23T16:38:48.295Z Status: PASS ## Lane Graph | Lane | Status | Affected Surface | Targets | Commands | Reason | | --- | --- | --- | --- | --- | --- | -| workspace-contract | PASS | Root tools future-state navigation and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane graph, command shape, targets, fixtures, and ownership compile before runtime. | -| game-workspace | SKIP | Game Workspace mock repository, Game Workspace UI, and Toolbox Progress/Build Path game-state bridge | tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/GameWorkspaceMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| game-design | SKIP | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | tests/playwright/tools/GameDesignMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/GameDesignMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| game-configuration | SKIP | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | tests/playwright/tools/GameConfigurationMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/GameConfigurationMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| asset-tool | SKIP | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | tests/playwright/tools/AssetToolMockRepository.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/AssetToolMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| build-path | SKIP | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | tests/playwright/tools/BuildPathProgressSimplification.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/BuildPathProgressSimplification.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| tools-progress | SKIP | Admin Tools Progress hydration, Toolbox Group view color model, and Game Build Path separation | tests/playwright/tools/ToolsProgressHydration.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolsProgressHydration.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| tool-navigation | SKIP | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | tests/playwright/tools/ToolNavigationPrevNext.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolNavigationPrevNext.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| tool-display-mode | SKIP | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| tool-images | SKIP | Toolbox registry image contract, Toolbox card image rendering, and Tool Display Mode image fallback | tests/playwright/tools/ToolImageRegistry.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/ToolImageRegistry.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | -| tool-runtime | SKIP | Active public toolbox and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| workspace-contract | PASS | Root tools future-state navigation and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane graph, command shape, targets, fixtures, and ownership compile before runtime. | +| game-hub | SKIP | Game Hub mock repository, Game Hub UI, and Toolbox Progress/Build Path game-state bridge | tests/playwright/tools/GameHubMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/GameHubMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| game-design | SKIP | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | tests/playwright/tools/GameDesignMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/GameDesignMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| game-configuration | SKIP | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | tests/playwright/tools/GameConfigurationMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/GameConfigurationMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| asset-tool | SKIP | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | tests/playwright/tools/AssetToolMockRepository.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/AssetToolMockRepository.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| build-path | SKIP | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | tests/playwright/tools/BuildPathProgressSimplification.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/BuildPathProgressSimplification.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| tools-progress | SKIP | Admin Tools Progress hydration, Toolbox Group view color model, and Game Build Path separation | tests/playwright/tools/ToolsProgressHydration.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolsProgressHydration.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| tool-navigation | SKIP | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | tests/playwright/tools/ToolNavigationPrevNext.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolNavigationPrevNext.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| tool-display-mode | SKIP | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| tool-images | SKIP | Toolbox registry image contract, Toolbox card image rendering, and Tool Display Mode image fallback | tests/playwright/tools/ToolImageRegistry.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/ToolImageRegistry.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | +| tool-runtime | SKIP | Active public toolbox and Tool Template V2 contract | tests/playwright/tools/RootToolsFutureState.spec.mjs | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Lane was not selected. | | game-runtime | SKIP | Deprecated archive/v1-v2/games reference coverage | none | none | Lane was not selected. | | integration | SKIP | Integration handoff behavior | none | none | Lane was not selected. | -| engine-src | SKIP | src/ engine and shared runtime capability behavior | tests/core/EngineCoreBoundaryBaseline.test.mjs; tests/core/FrameClock.test.mjs; tests/core/FixedTicker.test.mjs; tests/assets/AssetLoaderSystem.test.mjs; tests/audio/AudioService.test.mjs; tests/input/InputMap.test.mjs; tests/input/KeyboardState.test.mjs; tests/input/MouseState.test.mjs; tests/input/GamepadInputAdapter.test.mjs; tests/input/GamepadHapticsService.test.mjs; tests/render/Renderer.test.mjs | C:\nvm4w\nodejs\node.exe scripts/run-node-test-files.mjs tests/core/EngineCoreBoundaryBaseline.test.mjs tests/core/FrameClock.test.mjs tests/core/FixedTicker.test.mjs tests/assets/AssetLoaderSystem.test.mjs tests/audio/AudioService.test.mjs tests/input/InputMap.test.mjs tests/input/KeyboardState.test.mjs tests/input/MouseState.test.mjs tests/input/GamepadInputAdapter.test.mjs tests/input/GamepadHapticsService.test.mjs tests/render/Renderer.test.mjs | Lane was not selected. | +| engine-src | SKIP | src/ engine and shared runtime capability behavior | tests/core/EngineCoreBoundaryBaseline.test.mjs; tests/core/FrameClock.test.mjs; tests/core/FixedTicker.test.mjs; tests/assets/AssetLoaderSystem.test.mjs; tests/audio/AudioService.test.mjs; tests/input/InputMap.test.mjs; tests/input/KeyboardState.test.mjs; tests/input/MouseState.test.mjs; tests/input/GamepadInputAdapter.test.mjs; tests/input/GamepadHapticsService.test.mjs; tests/render/Renderer.test.mjs | "C:\\Program Files\\nodejs\\node.exe" scripts/run-node-test-files.mjs tests/core/EngineCoreBoundaryBaseline.test.mjs tests/core/FrameClock.test.mjs tests/core/FixedTicker.test.mjs tests/assets/AssetLoaderSystem.test.mjs tests/audio/AudioService.test.mjs tests/input/InputMap.test.mjs tests/input/KeyboardState.test.mjs tests/input/MouseState.test.mjs tests/input/GamepadInputAdapter.test.mjs tests/input/GamepadHapticsService.test.mjs tests/render/Renderer.test.mjs | Lane was not selected. | | samples | SKIP | Deprecated archive/v1-v2/samples reference coverage | none | none | Lane was not selected. | ## Compilation Failures diff --git a/docs_build/dev/reports/lane_deduplication_report.md b/docs_build/dev/reports/lane_deduplication_report.md index ca9adb0a3..112fe485b 100644 --- a/docs_build/dev/reports/lane_deduplication_report.md +++ b/docs_build/dev/reports/lane_deduplication_report.md @@ -1,6 +1,6 @@ # Lane Deduplication Report -Generated: 2026-06-21T00:10:10.215Z +Generated: 2026-06-23T16:38:48.295Z Status: PASS ## Summary diff --git a/docs_build/dev/reports/lane_input_validation_report.md b/docs_build/dev/reports/lane_input_validation_report.md index 0c7546c68..2911bcfe5 100644 --- a/docs_build/dev/reports/lane_input_validation_report.md +++ b/docs_build/dev/reports/lane_input_validation_report.md @@ -1,6 +1,6 @@ # Lane Input Validation Report -Generated: 2026-06-21T00:10:10.218Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS ## Input Files diff --git a/docs_build/dev/reports/lane_manifests/workspace-contract.json b/docs_build/dev/reports/lane_manifests/workspace-contract.json index fb867147c..84148f335 100644 --- a/docs_build/dev/reports/lane_manifests/workspace-contract.json +++ b/docs_build/dev/reports/lane_manifests/workspace-contract.json @@ -1,17 +1,17 @@ { - "commandsHash": "43673d11b6eb7f6f", + "commandsHash": "e2ef303e88f244be", "dependencies": [], - "dependencyGraphHash": "53e56ebae6e84541", + "dependencyGraphHash": "4dcb31ccda1bdb5a", "fileHashes": { "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", - "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", - "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", - "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", - "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", - "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", - "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", - "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85", - "tests/playwright/tools/RootToolsFutureState.spec.mjs": "032c57abb5289a23" + "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", + "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", + "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", + "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", + "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", + "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", + "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1", + "tests/playwright/tools/RootToolsFutureState.spec.mjs": "fda17eab0bf8a418" }, "fixtures": [], "helpers": [ @@ -30,15 +30,15 @@ "tests/helpers/playwrightV8CoverageReporter.mjs", "tests/helpers/workspaceV2CoverageReporter.mjs" ], - "inputHash": "5145d05e2885e902", + "inputHash": "61358ffa71dc7104", "lane": "workspace-contract", - "laneDefinitionHash": "95059517ac8a6497", - "manifestHash": "d98d3245976c92f4", + "laneDefinitionHash": "97570e267d472bc8", + "manifestHash": "fdada39983df06d5", "ownership": "tools", "tests": [ "tests/playwright/tools/RootToolsFutureState.spec.mjs" ], "version": 1, - "generatedAt": "2026-06-21T00:10:08.792Z", + "generatedAt": "2026-06-23T16:38:47.630Z", "source": "generated" } diff --git a/docs_build/dev/reports/lane_runtime_optimization_report.md b/docs_build/dev/reports/lane_runtime_optimization_report.md index a8d52d441..4152520f7 100644 --- a/docs_build/dev/reports/lane_runtime_optimization_report.md +++ b/docs_build/dev/reports/lane_runtime_optimization_report.md @@ -1,6 +1,6 @@ # Lane Runtime Optimization Report -Generated: 2026-06-21T00:10:10.215Z +Generated: 2026-06-23T16:38:48.295Z Status: PASS ## Runtime Cost Summary @@ -28,7 +28,7 @@ No zero-browser, compilation, or dependency blockers were found. | Lane | Snapshot | Warm Start | Hydration | Baseline Browser Launches | Scheduled Browser Launches | Commands | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | -| workspace-contract | INVALIDATED | INVALIDATED | INVALIDATED | 1 | 1 | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | +| workspace-contract | INVALIDATED | INVALIDATED | INVALIDATED | 1 | 1 | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | ## Runtime Savings Observations diff --git a/docs_build/dev/reports/lane_snapshot_report.md b/docs_build/dev/reports/lane_snapshot_report.md index 5037cf0b8..727fddbd0 100644 --- a/docs_build/dev/reports/lane_snapshot_report.md +++ b/docs_build/dev/reports/lane_snapshot_report.md @@ -1,6 +1,6 @@ # Lane Snapshot Report -Generated: 2026-06-21T00:10:10.216Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS Snapshot directory: docs_build/dev/reports/lane_snapshots @@ -17,7 +17,7 @@ Prevented manifest traversal: 0 | Lane | Status | Snapshot Path | Manifest Hash | Dependency Graph Hash | Helper Graph Hash | Fixture Graph Hash | Runtime Config Hash | Execution Graph Hash | Snapshot Hash | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_snapshots/workspace-contract.json | d98d3245976c92f4 | 53e56ebae6e84541 | 7d3db838f9f780e0 | 6c4fac7630b0b6f3 | e5ac9cbc103c3984 | 51dc9d019b71a923 | 13ffd9b4d27b4d42 | Lane snapshot executionGraphHash changed for workspace-contract.; Lane snapshot inputHash changed for workspace-contract.; Lane snapshot manifestHash changed for workspace-contract.; Lane snapshot snapshotHash changed for workspace-contract.; Lane snapshot warmStartHash changed for workspace-contract. | +| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_snapshots/workspace-contract.json | fdada39983df06d5 | 4dcb31ccda1bdb5a | 6cee3b9e8c76ce0d | 6c4fac7630b0b6f3 | 2ba7784af3a4df8d | 98ec1cea6aaaef57 | 54af1dc18e270910 | Lane snapshot commandsHash changed for workspace-contract.; Lane snapshot dependencyGraphHash changed for workspace-contract.; Lane snapshot executionGraphHash changed for workspace-contract.; Lane snapshot helperGraphHash changed for workspace-contract.; Lane snapshot inputHash changed for workspace-contract.; Lane snapshot laneDefinitionHash changed for workspace-contract.; Lane snapshot manifestHash changed for workspace-contract.; Lane snapshot runtimeConfigurationHash changed for workspace-contract.; Lane snapshot snapshotHash changed for workspace-contract.; Lane snapshot warmStartHash changed for workspace-contract. | ## Snapshot Validation Findings diff --git a/docs_build/dev/reports/lane_snapshots/workspace-contract.json b/docs_build/dev/reports/lane_snapshots/workspace-contract.json index e0fe4f05f..c2111ff5a 100644 --- a/docs_build/dev/reports/lane_snapshots/workspace-contract.json +++ b/docs_build/dev/reports/lane_snapshots/workspace-contract.json @@ -1,18 +1,18 @@ { - "commandsHash": "43673d11b6eb7f6f", + "commandsHash": "e2ef303e88f244be", "dependencyGateStatus": "PASS", "dependencyGraph": { "dependencies": [], - "dependencyGraphHash": "53e56ebae6e84541", + "dependencyGraphHash": "4dcb31ccda1bdb5a", "importHashes": { "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", - "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", - "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", - "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", - "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", - "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", - "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", - "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" + "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", + "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", + "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", + "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", + "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", + "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", + "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" }, "imports": [ "src/dev-runtime/admin/admin-notes-directory.mjs", @@ -25,8 +25,8 @@ "tests/helpers/workspaceV2CoverageReporter.mjs" ] }, - "dependencyGraphHash": "53e56ebae6e84541", - "executionGraphHash": "51dc9d019b71a923", + "dependencyGraphHash": "4dcb31ccda1bdb5a", + "executionGraphHash": "98ec1cea6aaaef57", "fixtureGraph": { "fixtureHashes": {}, "fixtures": [] @@ -34,10 +34,10 @@ "fixtureGraphHash": "6c4fac7630b0b6f3", "helperGraph": { "helperHashes": { - "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", - "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", - "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", - "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" + "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", + "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", + "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", + "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" }, "helpers": [ "tests/helpers/playwrightRepoServer.mjs", @@ -46,22 +46,22 @@ "tests/helpers/workspaceV2CoverageReporter.mjs" ] }, - "helperGraphHash": "7d3db838f9f780e0", - "inputHash": "5145d05e2885e902", + "helperGraphHash": "6cee3b9e8c76ce0d", + "inputHash": "61358ffa71dc7104", "lane": "workspace-contract", "laneCompilationStatus": "PASS", - "laneDefinitionHash": "95059517ac8a6497", + "laneDefinitionHash": "97570e267d472bc8", "manifest": { "fileHashes": { "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", - "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", - "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", - "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", - "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", - "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", - "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", - "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85", - "tests/playwright/tools/RootToolsFutureState.spec.mjs": "032c57abb5289a23" + "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", + "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", + "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", + "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", + "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", + "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", + "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1", + "tests/playwright/tools/RootToolsFutureState.spec.mjs": "fda17eab0bf8a418" }, "fixtures": [], "helpers": [ @@ -80,42 +80,42 @@ "tests/helpers/playwrightV8CoverageReporter.mjs", "tests/helpers/workspaceV2CoverageReporter.mjs" ], - "manifestHash": "d98d3245976c92f4", + "manifestHash": "fdada39983df06d5", "manifestPath": "docs_build/dev/reports/lane_manifests/workspace-contract.json", "source": "generated", "tests": [ "tests/playwright/tools/RootToolsFutureState.spec.mjs" ] }, - "manifestHash": "d98d3245976c92f4", + "manifestHash": "fdada39983df06d5", "ownership": "tools", "runtimeConfiguration": { "affectedSurface": "Root tools future-state navigation and Tool Template V2 contract", "commands": [ { "args": [ - "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js", + "C:\\Users\\davidq\\Documents\\github\\GameFoundryStudio\\node_modules\\@playwright\\test\\cli.js", "test", "tests/playwright/tools/RootToolsFutureState.spec.mjs", "--project=playwright", "--workers=1", "--reporter=list" ], - "command": "C:\\nvm4w\\nodejs\\node.exe", + "command": "C:\\Program Files\\nodejs\\node.exe", "targets": [ "tests/playwright/tools/RootToolsFutureState.spec.mjs" ], "type": "playwright" } ], - "commandsHash": "43673d11b6eb7f6f", - "laneConfigHash": "09eaf9063f7694e3", + "commandsHash": "e2ef303e88f244be", + "laneConfigHash": "66a7f2d814114595", "requiresPreflight": true, "requiresSamplesFlag": false }, - "runtimeConfigurationHash": "e5ac9cbc103c3984", - "snapshotHash": "13ffd9b4d27b4d42", + "runtimeConfigurationHash": "2ba7784af3a4df8d", + "snapshotHash": "54af1dc18e270910", "version": 1, - "warmStartHash": "f32a5831d6b39914", - "generatedAt": "2026-06-21T00:10:10.207Z" + "warmStartHash": "89d30ba62849a51c", + "generatedAt": "2026-06-23T16:38:48.293Z" } diff --git a/docs_build/dev/reports/lane_warm_start_report.md b/docs_build/dev/reports/lane_warm_start_report.md index d06a40a33..8ce74ca47 100644 --- a/docs_build/dev/reports/lane_warm_start_report.md +++ b/docs_build/dev/reports/lane_warm_start_report.md @@ -1,6 +1,6 @@ # Lane Warm-Start Report -Generated: 2026-06-21T00:10:10.216Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS Warm-start directory: docs_build/dev/reports/lane_warm_starts @@ -17,7 +17,7 @@ Prevented lane graph assembly: 0 | Lane | Status | Warm-Start Path | Manifest Hash | Warm-Start Hash | Dependency Hydration Hash | Reason | | --- | --- | --- | --- | --- | --- | --- | -| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_warm_starts/workspace-contract.json | d98d3245976c92f4 | f32a5831d6b39914 | 355ba7a85dbb3cdb | Warm-start inputHash changed for workspace-contract.; Warm-start manifestHash changed for workspace-contract.; Warm-start warmStartHash changed for workspace-contract. | +| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_warm_starts/workspace-contract.json | fdada39983df06d5 | 89d30ba62849a51c | e9956a75c3585e86 | Warm-start commandsHash changed for workspace-contract.; Warm-start dependencyGraphHash changed for workspace-contract.; Warm-start dependencyHydrationHash changed for workspace-contract.; Warm-start inputHash changed for workspace-contract.; Warm-start laneConfigHash changed for workspace-contract.; Warm-start laneDefinitionHash changed for workspace-contract.; Warm-start manifestHash changed for workspace-contract.; Warm-start warmStartHash changed for workspace-contract. | ## Fast-Fail Safeguards diff --git a/docs_build/dev/reports/lane_warm_starts/workspace-contract.json b/docs_build/dev/reports/lane_warm_starts/workspace-contract.json index 3c29e890d..88a7c314a 100644 --- a/docs_build/dev/reports/lane_warm_starts/workspace-contract.json +++ b/docs_build/dev/reports/lane_warm_starts/workspace-contract.json @@ -1,15 +1,15 @@ { - "commandsHash": "43673d11b6eb7f6f", - "dependencyGraphHash": "53e56ebae6e84541", + "commandsHash": "e2ef303e88f244be", + "dependencyGraphHash": "4dcb31ccda1bdb5a", "dependencyHydration": { - "dependencyHydrationHash": "355ba7a85dbb3cdb", + "dependencyHydrationHash": "e9956a75c3585e86", "fixtureHashes": {}, "fixtures": [], "helperHashes": { - "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", - "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", - "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", - "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" + "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", + "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", + "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", + "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" }, "helpers": [ "tests/helpers/playwrightRepoServer.mjs", @@ -19,13 +19,13 @@ ], "importHashes": { "src/dev-runtime/admin/admin-notes-directory.mjs": "2eadf130de0ef0df", - "src/dev-runtime/admin/admin-notes-menu.mjs": "1143d3a104fb4b4f", - "src/dev-runtime/persistence/mock-db-store.js": "8c9c167f6c5adcfc", - "src/dev-runtime/server/local-api-router.mjs": "54200ad3dc0ef7b2", - "tests/helpers/playwrightRepoServer.mjs": "a1dc02a78c92807b", - "tests/helpers/playwrightStorageIsolation.mjs": "22604b3e338d2c4a", - "tests/helpers/playwrightV8CoverageReporter.mjs": "a1b81069fef85fd6", - "tests/helpers/workspaceV2CoverageReporter.mjs": "2cf6bcedc7e43c85" + "src/dev-runtime/admin/admin-notes-menu.mjs": "38ce15ab63418748", + "src/dev-runtime/persistence/mock-db-store.js": "894fdc5041524dca", + "src/dev-runtime/server/local-api-router.mjs": "c70f436e8933b52c", + "tests/helpers/playwrightRepoServer.mjs": "1cad2a3d1221ef95", + "tests/helpers/playwrightStorageIsolation.mjs": "8057ea0c3ec2c8ac", + "tests/helpers/playwrightV8CoverageReporter.mjs": "290159be068de479", + "tests/helpers/workspaceV2CoverageReporter.mjs": "08d4c995f88aebe1" }, "imports": [ "src/dev-runtime/admin/admin-notes-directory.mjs", @@ -38,16 +38,16 @@ "tests/helpers/workspaceV2CoverageReporter.mjs" ] }, - "dependencyHydrationHash": "355ba7a85dbb3cdb", - "inputHash": "5145d05e2885e902", + "dependencyHydrationHash": "e9956a75c3585e86", + "inputHash": "61358ffa71dc7104", "lane": "workspace-contract", - "laneConfigHash": "09eaf9063f7694e3", - "laneDefinitionHash": "95059517ac8a6497", - "manifestHash": "d98d3245976c92f4", + "laneConfigHash": "66a7f2d814114595", + "laneDefinitionHash": "97570e267d472bc8", + "manifestHash": "fdada39983df06d5", "ownership": "tools", - "warmStartHash": "f32a5831d6b39914", + "warmStartHash": "89d30ba62849a51c", "version": 1, - "generatedAt": "2026-06-21T00:10:08.802Z", + "generatedAt": "2026-06-23T16:38:47.635Z", "manifestPath": "docs_build/dev/reports/lane_manifests/workspace-contract.json", "sourceManifest": "generated" } diff --git a/docs_build/dev/reports/monolith_trigger_removal_report.md b/docs_build/dev/reports/monolith_trigger_removal_report.md index 97a55c340..266ecc808 100644 --- a/docs_build/dev/reports/monolith_trigger_removal_report.md +++ b/docs_build/dev/reports/monolith_trigger_removal_report.md @@ -1,6 +1,6 @@ # Monolith Trigger Removal Report -Generated: 2026-06-21T00:11:18.108Z +Generated: 2026-06-23T16:38:57.092Z Status: PASS ## Removed Broad Execution Triggers @@ -30,7 +30,7 @@ Status: PASS No-argument safe mode active for this invocation: No Scheduled runtime lanes: workspace-contract Executed lanes: workspace-contract -Skipped lanes: game-workspace, game-design, game-configuration, asset-tool, build-path, tools-progress, tool-navigation, tool-display-mode, tool-images, tool-runtime, game-runtime, integration, engine-src, samples +Skipped lanes: game-hub, game-design, game-configuration, asset-tool, build-path, tools-progress, tool-navigation, tool-display-mode, tool-images, tool-runtime, game-runtime, integration, engine-src, samples Full samples smoke: SKIP - Skipped because changed files do not modify sample JSON or shared sample loader/framework behavior. Unaffected lane execution blocked: Yes diff --git a/docs_build/dev/reports/persistent_lane_manifest_report.md b/docs_build/dev/reports/persistent_lane_manifest_report.md index f3f131d4a..30fff9f34 100644 --- a/docs_build/dev/reports/persistent_lane_manifest_report.md +++ b/docs_build/dev/reports/persistent_lane_manifest_report.md @@ -1,6 +1,6 @@ # Persistent Lane Manifest Report -Generated: 2026-06-21T00:10:10.218Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS Manifest directory: docs_build/dev/reports/lane_manifests @@ -15,13 +15,13 @@ Prevented discovery scans: 0 | Lane | Status | Manifest Path | Input Hash | Manifest Hash | Reason | | --- | --- | --- | --- | --- | --- | -| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_manifests/workspace-contract.json | 5145d05e2885e902 | d98d3245976c92f4 | Persistent manifest input hash changed for workspace-contract.; Persistent manifest hash changed for workspace-contract. | +| workspace-contract | INVALIDATED | docs_build/dev/reports/lane_manifests/workspace-contract.json | 61358ffa71dc7104 | fdada39983df06d5 | Persistent manifest lane definition hash changed for workspace-contract. | ## Persisted Manifest Files | Lane | Ownership | Source | Tests | Helpers | Fixtures | Dependency Graph Hash | Manifest Hash | | --- | --- | --- | --- | --- | --- | --- | --- | -| workspace-contract | tools | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | 53e56ebae6e84541 | d98d3245976c92f4 | +| workspace-contract | tools | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | 4dcb31ccda1bdb5a | fdada39983df06d5 | ## Fast-Fail Enforcement diff --git a/docs_build/dev/reports/playwright_discovery_ownership_report.md b/docs_build/dev/reports/playwright_discovery_ownership_report.md index a5a1b3dea..b9ff99fa1 100644 --- a/docs_build/dev/reports/playwright_discovery_ownership_report.md +++ b/docs_build/dev/reports/playwright_discovery_ownership_report.md @@ -1,6 +1,6 @@ # Playwright Discovery Ownership Report -Generated: 2026-06-21T00:10:10.169Z +Generated: 2026-06-23T16:38:48.277Z Status: PASS ## Discovery-Time Ownership diff --git a/docs_build/dev/reports/playwright_discovery_scope_report.md b/docs_build/dev/reports/playwright_discovery_scope_report.md index 7ba7a7a0e..9aac01c6c 100644 --- a/docs_build/dev/reports/playwright_discovery_scope_report.md +++ b/docs_build/dev/reports/playwright_discovery_scope_report.md @@ -1,6 +1,6 @@ # Playwright Discovery Scope Report -Generated: 2026-06-21T00:10:10.176Z +Generated: 2026-06-23T16:38:48.279Z Status: PASS Scoped discovery: Yes diff --git a/docs_build/dev/reports/playwright_structure_audit.md b/docs_build/dev/reports/playwright_structure_audit.md index dc6386691..acc8a33b1 100644 --- a/docs_build/dev/reports/playwright_structure_audit.md +++ b/docs_build/dev/reports/playwright_structure_audit.md @@ -1,6 +1,6 @@ # Playwright Structure Audit -Generated: 2026-06-21T00:10:10.141Z +Generated: 2026-06-23T16:38:48.260Z Status: PASS ## Lane Directories diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt index 86cc56dfe..5a0ac58e8 100644 --- a/docs_build/dev/reports/playwright_v8_coverage_report.txt +++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt @@ -17,13 +17,26 @@ Exercised tool entry points detected: (0%) Theme V2 Shared JS - not exercised by this Playwright run Changed runtime JS files covered: -(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only Files with executed line/function counts where available: (100%) none - no covered runtime files Uncovered or low-coverage changed JS files: -(0%) src/dev-runtime/server/local-api-router.mjs - WARNING: uncovered changed runtime JS file; advisory only +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: uncovered changed runtime JS file; advisory only +(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only +(0%) toolbox/text-to-speech/text2speech.js - WARNING: uncovered changed runtime JS file; advisory only +(0%) toolbox/text-to-speech/tts-profile-store.js - WARNING: uncovered changed runtime JS file; advisory only Changed JS files considered: -(0%) src/dev-runtime/server/local-api-router.mjs - changed JS file not collected as browser runtime coverage +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - changed JS file not collected as browser runtime coverage +(0%) tests/dev-runtime/DbSeedIntegrity.test.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/tools/MessagesPlaybackSource.test.mjs - changed JS file not collected as browser runtime coverage +(0%) tests/tools/Text2SpeechShell.test.mjs - changed JS file not collected as browser runtime coverage +(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage +(0%) toolbox/text-to-speech/text2speech.js - changed JS file not collected as browser runtime coverage +(0%) toolbox/text-to-speech/tts-profile-store.js - changed JS file not collected as browser runtime coverage diff --git a/docs_build/dev/reports/retry_suppression_report.md b/docs_build/dev/reports/retry_suppression_report.md index 5391d7a38..ec68cd7e2 100644 --- a/docs_build/dev/reports/retry_suppression_report.md +++ b/docs_build/dev/reports/retry_suppression_report.md @@ -1,7 +1,7 @@ # Retry Suppression Report -Generated: 2026-06-21T00:11:18.107Z -Status: PASS +Generated: 2026-06-23T16:38:57.091Z +Status: WARN ## Summary @@ -15,7 +15,7 @@ Prevented repeated lane hydration: 0 | Fingerprint | Lane | Category | Retry Decision | Reason | | --- | --- | --- | --- | --- | -| none | none | none | No retry needed | No failures were observed. | +| fc8ad6ba552baa70 | workspace-contract | runtime failure | Allowed only on explicit targeted retry | Retry is allowed only when explicitly requested and must preserve the same targeted lane scope. | ## Enforcement Rules diff --git a/docs_build/dev/reports/slow_path_pruning_report.md b/docs_build/dev/reports/slow_path_pruning_report.md index 0fc071745..c56867eb8 100644 --- a/docs_build/dev/reports/slow_path_pruning_report.md +++ b/docs_build/dev/reports/slow_path_pruning_report.md @@ -1,13 +1,13 @@ # Slow Path Pruning Report -Generated: 2026-06-21T00:11:18.108Z +Generated: 2026-06-23T16:38:57.092Z Status: PASS Source timing evidence: docs_build/dev/reports/test_cleanup_performance_report.md (2026-05-26T21:18:42.199Z) ## Before / After Runtime Observations PR_26146_038 measured lane elapsed time: 169.71s -Current measured lane elapsed time: 67.79s +Current measured lane elapsed time: 8.76s PR_26146_038 actual browser launches: 4 Current actual browser launches: 1 Accidental no-argument browser launches prevented: 5 @@ -31,16 +31,16 @@ Validation cache hits: 18 | PR_26146_038 | tool-runtime | 19.10s | Asset Manager V2 temporary UAT context | | PR_26146_038 | integration | 14.50s | games index resolves Pong thumbnail from manifest preview role | | PR_26146_038 | tool-runtime | 10.10s | Preview Generator V2 real batch output | -| current targeted run | workspace-contract | 15.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | -| current targeted run | workspace-contract | 14.70s | tests\playwright\tools\RootToolsFutureState.spec.mjs:664:1 > representative active tool pages align center cleanup and registry group colors | -| current targeted run | workspace-contract | 13.10s | tests\playwright\tools\RootToolsFutureState.spec.mjs:562:1 > learn wireframe pages load with shared Theme V2 structure | -| current targeted run | workspace-contract | 11.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:482:1 > common header renders primary navigation order across active pages | -| current targeted run | workspace-contract | 2.30s | tests\playwright\tools\RootToolsFutureState.spec.mjs:641:1 > tool template future-state page loads from root Theme V2 paths | +| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | +| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:485:1 > common header renders primary navigation order across active pages | +| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:565:1 > learn wireframe pages load with shared Theme V2 structure | +| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:644:1 > tool template future-state page loads from root Theme V2 paths | +| current targeted run | workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:667:1 > representative active tool pages align center cleanup and registry group colors | ## Guardrails Full samples smoke: SKIP - Skipped because changed files do not modify sample JSON or shared sample loader/framework behavior. -Runtime failures observed: 0 +Runtime failures observed: 1 Runtime schedule status: PASS - Only no-argument broad defaults and safe Workspace legacy routing were pruned. diff --git a/docs_build/dev/reports/static_validation_report.md b/docs_build/dev/reports/static_validation_report.md index ef23805b9..1e75365b8 100644 --- a/docs_build/dev/reports/static_validation_report.md +++ b/docs_build/dev/reports/static_validation_report.md @@ -1,6 +1,6 @@ # Static Validation Report -Generated: 2026-06-21T00:10:10.202Z +Generated: 2026-06-23T16:38:48.289Z Status: PASS Static only: No Dry run: No @@ -22,7 +22,7 @@ Reason: No deterministic static validation failure was found. | invalid filename detection | PASS | Covered by Playwright structure audit. | | missing import detection | PASS | Covered by Playwright structure audit relative import checks. | | missing fixture detection | PASS | No missing fixture findings. | -| targeted file manifests | PASS | workspace-contract:d98d3245976c92f4 | +| targeted file manifests | PASS | workspace-contract:fdada39983df06d5 | | persistent lane manifests | PASS | workspace-contract:INVALIDATED | | lane warm-start reuse | PASS | workspace-contract:INVALIDATED | | dependency hydration reuse | PASS | workspace-contract:INVALIDATED | diff --git a/docs_build/dev/reports/targeted_file_manifest_report.md b/docs_build/dev/reports/targeted_file_manifest_report.md index fd6fbb064..0c90657e2 100644 --- a/docs_build/dev/reports/targeted_file_manifest_report.md +++ b/docs_build/dev/reports/targeted_file_manifest_report.md @@ -1,13 +1,13 @@ # Targeted File Manifest Report -Generated: 2026-06-21T00:10:10.217Z +Generated: 2026-06-23T16:38:48.296Z Status: PASS ## Manifest-Generated Lane Inputs | Lane | Ownership | Status | Source | Tests | Helpers | Fixtures | Imports / Dependencies | Dependency Graph Hash | Manifest Hash | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| workspace-contract | tools | PASS | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | 53e56ebae6e84541 | d98d3245976c92f4 | Manifest ownership, helpers, fixtures, imports, and command targets are deterministic before runtime. | +| workspace-contract | tools | PASS | generated | tests/playwright/tools/RootToolsFutureState.spec.mjs | tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | none | src/dev-runtime/admin/admin-notes-directory.mjs; src/dev-runtime/admin/admin-notes-menu.mjs; src/dev-runtime/persistence/mock-db-store.js; src/dev-runtime/server/local-api-router.mjs; tests/helpers/playwrightRepoServer.mjs; tests/helpers/playwrightStorageIsolation.mjs; tests/helpers/playwrightV8CoverageReporter.mjs; tests/helpers/workspaceV2CoverageReporter.mjs | 4dcb31ccda1bdb5a | fdada39983df06d5 | Manifest ownership, helpers, fixtures, imports, and command targets are deterministic before runtime. | ## Discovery Expansion Control diff --git a/docs_build/dev/reports/test_cleanup_performance_report.md b/docs_build/dev/reports/test_cleanup_performance_report.md index db3a814e7..00df617c4 100644 --- a/docs_build/dev/reports/test_cleanup_performance_report.md +++ b/docs_build/dev/reports/test_cleanup_performance_report.md @@ -1,11 +1,11 @@ # Test Cleanup Performance Report -Generated: 2026-06-21T00:11:18.107Z -Status: PASS +Generated: 2026-06-23T16:38:57.091Z +Status: WARN ## Cost Summary -Total measured lane elapsed time: 67.79s +Total measured lane elapsed time: 8.76s Actual browser launch count: 1 Scheduled browser launch count: 1 Baseline browser launch count: 1 @@ -23,8 +23,8 @@ Prevented redundant dependency traversal: 0 | Lane | Status | Elapsed | Browser Launches | Reason | | --- | --- | --- | --- | --- | -| workspace-contract | PASS | 67.79s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | -| game-workspace | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | +| workspace-contract | FAIL | 8.76s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | +| game-hub | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | | game-design | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | | game-configuration | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | | asset-tool | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | @@ -43,11 +43,11 @@ Prevented redundant dependency traversal: 0 | Lane | Duration | Test | Command | | --- | --- | --- | --- | -| workspace-contract | 15.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | -| workspace-contract | 14.70s | tests\playwright\tools\RootToolsFutureState.spec.mjs:664:1 > representative active tool pages align center cleanup and registry group colors | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | -| workspace-contract | 13.10s | tests\playwright\tools\RootToolsFutureState.spec.mjs:562:1 > learn wireframe pages load with shared Theme V2 structure | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | -| workspace-contract | 11.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:482:1 > common header renders primary navigation order across active pages | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | -| workspace-contract | 2.30s | tests\playwright\tools\RootToolsFutureState.spec.mjs:641:1 > tool template future-state page loads from root Theme V2 paths | C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:485:1 > common header renders primary navigation order across active pages | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:565:1 > learn wireframe pages load with shared Theme V2 structure | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:644:1 > tool template future-state page loads from root Theme V2 paths | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:667:1 > representative active tool pages align center cleanup and registry group colors | "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list | ## Prevented Broad Execution diff --git a/docs_build/dev/reports/test_cleanup_routing_report.md b/docs_build/dev/reports/test_cleanup_routing_report.md index ed7ec4550..542854bf0 100644 --- a/docs_build/dev/reports/test_cleanup_routing_report.md +++ b/docs_build/dev/reports/test_cleanup_routing_report.md @@ -1,6 +1,6 @@ # Test Cleanup Routing Report -Generated: 2026-06-21T00:11:18.108Z +Generated: 2026-06-23T16:38:57.092Z Status: PASS ## Representative Routing Cases @@ -33,7 +33,7 @@ Full samples smoke decision: SKIP - Skipped because changed files do not modify | test:lane:tool-images | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane tool-images | | test:lane:game-configuration | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-configuration | | test:lane:game-design | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-design | -| test:lane:game-workspace | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-workspace | +| test:lane:game-hub | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-hub | | test:lane:tool-runtime | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane tool-runtime | | test:lane:game-runtime | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane game-runtime | | test:lane:integration | PASS | node ./scripts/run-targeted-test-lanes.mjs --lane integration | diff --git a/docs_build/dev/reports/testing_lane_execution_report.md b/docs_build/dev/reports/testing_lane_execution_report.md index c9cbce468..03e150e46 100644 --- a/docs_build/dev/reports/testing_lane_execution_report.md +++ b/docs_build/dev/reports/testing_lane_execution_report.md @@ -1,15 +1,15 @@ # Testing Lane Execution Report -Generated: 2026-06-21T00:11:18.159Z +Generated: 2026-06-23T16:38:57.102Z Dry run: No ## Summary -PASS: 1 +PASS: 0 WARN: 0 -FAIL: 0 +FAIL: 1 SKIP: 14 -Total lane elapsed time: 67.79s +Total lane elapsed time: 8.76s Actual browser launches: 1 ## Full Samples Smoke @@ -21,7 +21,7 @@ Reason: Skipped because changed files do not modify sample JSON or shared sample Status: PASS Reason: Runner preflight and Playwright structure audit passed before expensive lane execution. -Command: C:\nvm4w\nodejs\node.exe scripts/audit-playwright-test-locations.mjs --discovery-report docs_build/dev/reports/playwright_discovery_ownership_report.md --scope-report docs_build/dev/reports/playwright_discovery_scope_report.md --scan-report docs_build/dev/reports/filesystem_scan_reduction_report.md --lanes workspace-contract --targets tests/playwright/tools/RootToolsFutureState.spec.mjs --helpers tests/helpers/playwrightRepoServer.mjs,tests/helpers/playwrightStorageIsolation.mjs,tests/helpers/playwrightV8CoverageReporter.mjs,tests/helpers/workspaceV2CoverageReporter.mjs +Command: "C:\\Program Files\\nodejs\\node.exe" scripts/audit-playwright-test-locations.mjs --discovery-report docs_build/dev/reports/playwright_discovery_ownership_report.md --scope-report docs_build/dev/reports/playwright_discovery_scope_report.md --scan-report docs_build/dev/reports/filesystem_scan_reduction_report.md --lanes workspace-contract --targets tests/playwright/tools/RootToolsFutureState.spec.mjs --helpers tests/helpers/playwrightRepoServer.mjs,tests/helpers/playwrightStorageIsolation.mjs,tests/helpers/playwrightV8CoverageReporter.mjs,tests/helpers/workspaceV2CoverageReporter.mjs Details: none ## Dependency Gate @@ -49,9 +49,9 @@ Validation computations: 10 ## Failure Fingerprints -Status: PASS +Status: WARN Deterministic setup failures: 0 -Runtime failures: 0 +Runtime failures: 1 Flaky/transient failures: 0 Infrastructure failures: 0 Prevented reruns: 0 @@ -105,15 +105,15 @@ Prevented Workspace lane reruns: 0 | Lane | Status | Elapsed | Browser Launches | Executed/Skipped Reason | Affected Surface | Fixtures / Inputs | | --- | --- | --- | --- | --- | --- | --- | -| workspace-contract | PASS | 67.79s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | Root tools future-state navigation and Tool Template V2 contract | repo-served root tools page; Tool Template V2 future-state page; Theme V2 shared partials and assets | -| game-workspace | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Workspace mock repository, Game Workspace UI, and Toolbox Progress/Build Path game-state bridge | repo-served Game Workspace page; repo-served Toolbox page with role simulation; in-memory SQL-shaped mock game repository | -| game-design | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | repo-served Game Design page; repo-served Toolbox Progress and Build Path views; in-memory SQL-shaped Game Design mock repository; Game Workspace mock game context | +| workspace-contract | FAIL | 8.76s | 1 | Workspace V2 command now validates the future-state tools surface without exercising deprecated toolbox/old_* routes. | Root tools future-state navigation and Tool Template V2 contract | repo-served root tools page; Tool Template V2 future-state page; Theme V2 shared partials and assets | +| game-hub | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Hub mock repository, Game Hub UI, and Toolbox Progress/Build Path game-state bridge | repo-served Game Hub page; repo-served Toolbox page with role simulation; in-memory SQL-shaped mock game repository | +| game-design | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Design mock repository, project purpose flow, validation overlay, capability demo authoring, and Toolbox progress handoff | repo-served Game Design page; repo-served Toolbox Progress and Build Path views; in-memory SQL-shaped Game Design mock repository; Game Hub mock game context | | game-configuration | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Game Configuration mock repository, Game Design handoff, configuration validation, user-facing output, and Toolbox progress handoff | repo-served Game Configuration page; repo-served Game Design page for handoff checks; repo-served Toolbox Progress and Build Path views; in-memory SQL-shaped Game Configuration mock repository; Game Design mock repository handoff | | asset-tool | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Asset Tool mock repository, Game Configuration readiness handoff, library records, import preview, and visible failure handling | repo-served Assets page; in-memory SQL-shaped Asset Tool mock repository; Game Configuration mock repository handoff; file-name/path-based import preview | -| build-path | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | repo-served Toolbox page; repo-served Admin Tools Progress page; Game Workspace mock game context; Toolbox role simulation | +| build-path | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Toolbox Build Path simplification, workflow status table, and Admin Tools Progress navigation | repo-served Toolbox page; repo-served Admin Tools Progress page; Game Hub mock game context; Toolbox role simulation | | tools-progress | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Admin Tools Progress hydration, Toolbox Group view color model, and Game Build Path separation | repo-served Admin Tools Progress page; repo-served Toolbox Group view; Toolbox registry build sequence; Game Build Path workflow table | -| tool-navigation | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | repo-served Admin Tools Progress page; repo-served Game Workspace, Game Design, and Game Configuration tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata | -| tool-display-mode | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | repo-served Game Workspace, Game Design, Game Configuration, and AI Assistant tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata; shared Theme V2 Tool Display Mode script | +| tool-navigation | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Admin Tools Progress tool route links, Tool Display Mode build-order previous/next controls, and Toolbox group fallback routing | repo-served Admin Tools Progress page; repo-served Game Hub, Game Design, and Game Configuration tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata | +| tool-display-mode | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Tool Display Mode identity row, registry-owned previous/next links, disabled text fallback, and multi-path group routing | repo-served Game Hub, Game Design, Game Configuration, and AI Assistant tool pages; repo-served Toolbox Group view with URL-selected accordion; Toolbox registry build sequence and route metadata; shared Theme V2 Tool Display Mode script | | tool-images | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Toolbox registry image contract, Toolbox card image rendering, and Tool Display Mode image fallback | Toolbox registry badge/tool image contract; repo-served Toolbox page; repo-served representative Toolbox tool pages; shared registry image fallback | | tool-runtime | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Active public toolbox and Tool Template V2 contract | repo-served root toolbox page; Tool Template V2 public page; Theme V2 shared partials and assets | | game-runtime | SKIP | 0ms | 0 | Lane was not selected for this targeted run. | Deprecated archive/v1-v2/games reference coverage | | @@ -125,18 +125,18 @@ Prevented Workspace lane reruns: 0 | Lane | Duration | Test | | --- | --- | --- | -| workspace-contract | 15.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | -| workspace-contract | 14.70s | tests\playwright\tools\RootToolsFutureState.spec.mjs:664:1 > representative active tool pages align center cleanup and registry group colors | -| workspace-contract | 13.10s | tests\playwright\tools\RootToolsFutureState.spec.mjs:562:1 > learn wireframe pages load with shared Theme V2 structure | -| workspace-contract | 11.20s | tests\playwright\tools\RootToolsFutureState.spec.mjs:482:1 > common header renders primary navigation order across active pages | -| workspace-contract | 2.30s | tests\playwright\tools\RootToolsFutureState.spec.mjs:641:1 > tool template future-state page loads from root Theme V2 paths | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:270:1 > root tools surface links current tool pages without old_* routes | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:485:1 > common header renders primary navigation order across active pages | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:565:1 > learn wireframe pages load with shared Theme V2 structure | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:644:1 > tool template future-state page loads from root Theme V2 paths | +| workspace-contract | 0ms | tests\playwright\tools\RootToolsFutureState.spec.mjs:667:1 > representative active tool pages align center cleanup and registry group colors | ## Commands ### workspace-contract -- PASS 67.79s C:\nvm4w\nodejs\node.exe "C:\\Users\\davidq\\Documents\\GitHub\\HTML-JavaScript-Gaming - 1\\node_modules\\@playwright\\test\\cli.js" test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list +- FAIL 8.76s "C:\\Program Files\\nodejs\\node.exe" C:\Users\davidq\Documents\github\GameFoundryStudio\node_modules\@playwright\test\cli.js test tests/playwright/tools/RootToolsFutureState.spec.mjs --project=playwright --workers=1 --reporter=list -### game-workspace +### game-hub - SKIP ### game-design diff --git a/docs_build/dev/reports/validation_cache_report.md b/docs_build/dev/reports/validation_cache_report.md index 2afd84b03..f9d0e606d 100644 --- a/docs_build/dev/reports/validation_cache_report.md +++ b/docs_build/dev/reports/validation_cache_report.md @@ -1,6 +1,6 @@ # Validation Cache Report -Generated: 2026-06-21T00:10:10.220Z +Generated: 2026-06-23T16:38:48.297Z Status: PASS ## Cache Summary @@ -12,34 +12,34 @@ Validations computed: 10 | Stage | Cache | Input Hash | Reused By | Invalidation Inputs | | --- | --- | --- | --- | --- | -| lane registration validation | MISS | 6a5b13bb47c3fad3 | initial computation | lane definitions change; package.json lane scripts change | -| runner preflight validation | MISS | 0633df51b798654a | initial computation | lane definitions change; fixture ownership changes; targeted files change | -| scoped discovery map | MISS | e195476e1978ec54 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | -| targeted file manifest validation | MISS | c5c4b7876c948959 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | -| lane warm-start validation | MISS | a00444576ec05d24 | initial computation | lane definitions change; targeted files change; ownership metadata changes; dependency graph changes; helper/fixture placement changes; lane configuration changes | -| structural ownership validation | MISS | f0b916009d8b7a2e | initial computation | fixture ownership changes; helper/import graph changes; targeted files change | -| lane compilation validation | MISS | 0e1346462ea720dd | initial computation | lane definitions change; targeted files change; fixture ownership changes | -| lane compilation validation | HIT | 0e1346462ea720dd | dependency validation input | unchanged within execution cycle | -| dependency validation | MISS | 94d1bf1c7924d46d | initial computation | dependency graph changes; lane definitions change; lane compilation input changes | -| lane snapshot validation | MISS | 7ebcc49fd0ce56c8 | initial computation | targeted files change; dependency graph changes; helper/fixture ownership changes; lane configuration changes; runtime configuration changes | -| zero-browser preflight | MISS | 568caf8342cb4bc0 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change; dependency graph changes | -| structural ownership validation | HIT | f0b916009d8b7a2e | static validation report | unchanged within execution cycle | -| structural ownership validation | HIT | f0b916009d8b7a2e | zero-browser preflight report | unchanged within execution cycle | -| scoped discovery map | HIT | e195476e1978ec54 | structural ownership validation input | unchanged within execution cycle | -| scoped discovery map | HIT | e195476e1978ec54 | discovery scope reporting | unchanged within execution cycle | -| targeted file manifest validation | HIT | c5c4b7876c948959 | lane input validation report | unchanged within execution cycle | -| targeted file manifest validation | HIT | c5c4b7876c948959 | runtime scheduling blockers | unchanged within execution cycle | -| lane warm-start validation | HIT | a00444576ec05d24 | warm-start report | unchanged within execution cycle | -| lane warm-start validation | HIT | a00444576ec05d24 | dependency hydration reuse report | unchanged within execution cycle | -| lane warm-start validation | HIT | a00444576ec05d24 | runtime scheduling | unchanged within execution cycle | -| lane snapshot validation | HIT | 7ebcc49fd0ce56c8 | lane snapshot report | unchanged within execution cycle | -| lane snapshot validation | HIT | 7ebcc49fd0ce56c8 | execution graph reuse report | unchanged within execution cycle | -| lane snapshot validation | HIT | 7ebcc49fd0ce56c8 | runtime scheduling | unchanged within execution cycle | -| lane compilation validation | HIT | 0e1346462ea720dd | lane compilation report | unchanged within execution cycle | -| lane compilation validation | HIT | 0e1346462ea720dd | runtime scheduling | unchanged within execution cycle | -| dependency validation | HIT | 94d1bf1c7924d46d | dependency report | unchanged within execution cycle | -| dependency validation | HIT | 94d1bf1c7924d46d | runtime scheduling | unchanged within execution cycle | -| zero-browser preflight | HIT | 568caf8342cb4bc0 | zero-browser report output | unchanged within execution cycle | +| lane registration validation | MISS | 52928e5ef56fae1e | initial computation | lane definitions change; package.json lane scripts change | +| runner preflight validation | MISS | b766210e324f884f | initial computation | lane definitions change; fixture ownership changes; targeted files change | +| scoped discovery map | MISS | 3b6288f10251cb39 | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | +| targeted file manifest validation | MISS | 3f6048ccdda17a8d | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change | +| lane warm-start validation | MISS | e24f0d440b1410dc | initial computation | lane definitions change; targeted files change; ownership metadata changes; dependency graph changes; helper/fixture placement changes; lane configuration changes | +| structural ownership validation | MISS | ecc95a2940cc1427 | initial computation | fixture ownership changes; helper/import graph changes; targeted files change | +| lane compilation validation | MISS | d9318e7005141134 | initial computation | lane definitions change; targeted files change; fixture ownership changes | +| lane compilation validation | HIT | d9318e7005141134 | dependency validation input | unchanged within execution cycle | +| dependency validation | MISS | 22637e871065d383 | initial computation | dependency graph changes; lane definitions change; lane compilation input changes | +| lane snapshot validation | MISS | a80d6a2d403c317b | initial computation | targeted files change; dependency graph changes; helper/fixture ownership changes; lane configuration changes; runtime configuration changes | +| zero-browser preflight | MISS | 56c385cc0885a49f | initial computation | lane definitions change; fixture ownership changes; helper/import graph changes; targeted files change; dependency graph changes | +| structural ownership validation | HIT | ecc95a2940cc1427 | static validation report | unchanged within execution cycle | +| structural ownership validation | HIT | ecc95a2940cc1427 | zero-browser preflight report | unchanged within execution cycle | +| scoped discovery map | HIT | 3b6288f10251cb39 | structural ownership validation input | unchanged within execution cycle | +| scoped discovery map | HIT | 3b6288f10251cb39 | discovery scope reporting | unchanged within execution cycle | +| targeted file manifest validation | HIT | 3f6048ccdda17a8d | lane input validation report | unchanged within execution cycle | +| targeted file manifest validation | HIT | 3f6048ccdda17a8d | runtime scheduling blockers | unchanged within execution cycle | +| lane warm-start validation | HIT | e24f0d440b1410dc | warm-start report | unchanged within execution cycle | +| lane warm-start validation | HIT | e24f0d440b1410dc | dependency hydration reuse report | unchanged within execution cycle | +| lane warm-start validation | HIT | e24f0d440b1410dc | runtime scheduling | unchanged within execution cycle | +| lane snapshot validation | HIT | a80d6a2d403c317b | lane snapshot report | unchanged within execution cycle | +| lane snapshot validation | HIT | a80d6a2d403c317b | execution graph reuse report | unchanged within execution cycle | +| lane snapshot validation | HIT | a80d6a2d403c317b | runtime scheduling | unchanged within execution cycle | +| lane compilation validation | HIT | d9318e7005141134 | lane compilation report | unchanged within execution cycle | +| lane compilation validation | HIT | d9318e7005141134 | runtime scheduling | unchanged within execution cycle | +| dependency validation | HIT | 22637e871065d383 | dependency report | unchanged within execution cycle | +| dependency validation | HIT | 22637e871065d383 | runtime scheduling | unchanged within execution cycle | +| zero-browser preflight | HIT | 56c385cc0885a49f | zero-browser report output | unchanged within execution cycle | ## Deterministic Invalidation Rules diff --git a/docs_build/dev/reports/zero_browser_preflight_report.md b/docs_build/dev/reports/zero_browser_preflight_report.md index 6b9f2cec7..2417f5424 100644 --- a/docs_build/dev/reports/zero_browser_preflight_report.md +++ b/docs_build/dev/reports/zero_browser_preflight_report.md @@ -1,6 +1,6 @@ # Zero-Browser Preflight Report -Generated: 2026-06-21T00:10:10.219Z +Generated: 2026-06-23T16:38:48.297Z Status: PASS ## Prevented Browser Launches diff --git a/src/dev-runtime/messages/messages-postgres-service.mjs b/src/dev-runtime/messages/messages-postgres-service.mjs index a326dc9ae..0d077b530 100644 --- a/src/dev-runtime/messages/messages-postgres-service.mjs +++ b/src/dev-runtime/messages/messages-postgres-service.mjs @@ -1,8 +1,4 @@ import { randomBytes } from "node:crypto"; -import { - createMessageStudioDefaultTtsProfiles, - createMessageStudioTtsProfileOptions, -} from "../../../toolbox/text-to-speech/text2speech.js"; import { createPostgresConnectionClient } from "../persistence/postgres-connection-client.mjs"; import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; @@ -28,21 +24,11 @@ const SEED_EMOTION_PROFILES = Object.freeze([ Object.freeze({ description: "Measured delivery for suspense, hidden lore, or strange events.", name: "Mysterious", pauseAfterMs: 260, pauseBeforeMs: 120, pitch: 0.92, rate: 0.88, volume: 0.85 }), Object.freeze({ description: "Synthetic delivery for mechanical or artificial characters.", name: "Robot", pauseAfterMs: 120, pauseBeforeMs: 40, pitch: 0.82, rate: 0.92, volume: 0.9 }), ]); -const MESSAGE_STUDIO_TTS_PROFILE_OPTIONS = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) - .map((profile) => Object.freeze({ - ...profile, - emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), - }))); -const SEED_TTS_PROFILES = Object.freeze(MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.map((profile) => Object.freeze({ - description: `${profile.name} from Text To Speech profile ownership.`, - language: profile.language || "en-US", - name: profile.name, - pitch: 1, - providerKey: profile.providerKey || "browser-speech", - rate: 1, - voiceName: profile.voiceName || "Default browser voice", - volume: 1, -}))); +const SEED_TTS_PROFILES = Object.freeze([ + Object.freeze({ description: "Default Text To Speech browser profile.", language: "en-US", name: "Default Balanced Profile", pitch: 1, providerKey: "browser-speech", rate: 1, voiceName: "Default browser voice", volume: 1 }), + Object.freeze({ description: "Starter Text To Speech browser profile.", language: "en-US", name: "Man Profile 1", pitch: 1, providerKey: "browser-speech", rate: 1, voiceName: "Default browser voice", volume: 1 }), + Object.freeze({ description: "Starter Text To Speech browser profile.", language: "en-US", name: "Woman Profile 2", pitch: 1, providerKey: "browser-speech", rate: 1, voiceName: "Default browser voice", volume: 1 }), +]); const SUPPORTED_TTS_PROVIDER_KEYS = Object.freeze([ "browser-speech", "elevenlabs", @@ -357,54 +343,23 @@ function ttsEmotionSettingFromEmotionProfile(profile) { }; } -function messageStudioTtsProfileOption(row) { - const rowName = normalizeText(row?.name).trim().toLowerCase(); - const rowKey = normalizeText(row?.key).trim(); - return MESSAGE_STUDIO_TTS_PROFILE_OPTIONS.find((profile) => { - return profile.key === rowKey || normalizeText(profile.name).trim().toLowerCase() === rowName; - }) || null; -} - -function emotionSettingsForTtsProfileRow(row, emotionRows = []) { - const option = messageStudioTtsProfileOption(row); - if (!option) { - return []; - } - const activeEmotionProfiles = emotionRows +function emotionSettingsForTtsProfileRow(_row, emotionRows = []) { + return emotionRows .map((profileRow) => emotionProfileFromRow(profileRow)) - .filter((profile) => profile.active !== false); - const byLabel = new Map(activeEmotionProfiles.map((profile) => [normalizeText(profile.name).trim().toLowerCase(), profile])); - const byEmotion = new Map(activeEmotionProfiles.map((profile) => [emotionSettingKey(profile.name), profile])); - return option.emotionSettings - .map((setting) => { - const emotionProfile = byLabel.get(normalizeText(setting.emotionLabel).trim().toLowerCase()) - || byEmotion.get(emotionSettingKey(setting.emotion)) - || null; - if (!emotionProfile) { - return null; - } - return { - ...ttsEmotionSettingFromEmotionProfile(emotionProfile), - pitch: Number(setting.pitch), - rate: Number(setting.rate), - ssmlLikePreset: setting.ssmlLikePreset || "normal", - volume: Number(setting.volume), - }; - }) - .filter(Boolean); + .filter((profile) => profile.active !== false) + .map((profile) => ttsEmotionSettingFromEmotionProfile(profile)); } function ttsProfileFromRow(row, emotionSettings = []) { - const profileOption = messageStudioTtsProfileOption(row); return { active: activeFromDatabase(row.active), - age: profileOption?.age || "", - ageFilter: profileOption?.ageFilter || profileOption?.age || "", + age: "", + ageFilter: "", createdAt: row.createdAt, createdBy: row.createdBy, description: row.description || "", emotionSettings, - gender: profileOption?.gender || "", + gender: "", key: row.key, language: row.language, name: row.name, @@ -414,7 +369,7 @@ function ttsProfileFromRow(row, emotionSettings = []) { status: activeFromDatabase(row.active) ? "Active" : "Inactive", updatedAt: row.updatedAt, updatedBy: row.updatedBy, - voice: profileOption?.voice || row.voiceName || "", + voice: row.voiceName || "", voiceName: row.voiceName || "", volume: Number(row.volume), }; diff --git a/tests/dev-runtime/DbSeedIntegrity.test.mjs b/tests/dev-runtime/DbSeedIntegrity.test.mjs index fe3f5e70f..595a163d6 100644 --- a/tests/dev-runtime/DbSeedIntegrity.test.mjs +++ b/tests/dev-runtime/DbSeedIntegrity.test.mjs @@ -100,8 +100,10 @@ test("Messages Local API seeds through the Postgres service and preserves respon assert.ok(manProfile, "Messages TTS profiles should include Man Profile 1 from Text To Speech"); assert.ok(womanProfile, "Messages TTS profiles should include Woman Profile 2 from Text To Speech"); assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced Profile"), true); - assert.deepEqual(manProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Neutral", "Calm", "Urgent"]); - assert.deepEqual(womanProfile.emotionSettings.map((setting) => setting.emotionLabel), ["Whisper", "Robot"]); + assert.equal(manProfile.emotionSettings.some((setting) => setting.emotionLabel === "Neutral"), true); + assert.equal(womanProfile.emotionSettings.some((setting) => setting.emotionLabel === "Robot"), true); + assert.equal(manProfile.gender, ""); + assert.equal(womanProfile.gender, ""); const created = await apiJson(server.baseUrl, "/api/messages/messages", { body: JSON.stringify({ diff --git a/tests/playwright/tools/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs index c4322ef24..cda607adc 100644 --- a/tests/playwright/tools/MessagesTool.spec.mjs +++ b/tests/playwright/tools/MessagesTool.spec.mjs @@ -3,8 +3,57 @@ import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; import { createMessagesPostgresClientStub } from "../../helpers/messagesPostgresClientStub.mjs"; import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; +import { TEXT_TO_SPEECH_PROFILE_STORAGE_KEY } from "../../../toolbox/text-to-speech/tts-profile-store.js"; const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const SAVED_TTS_PROFILES_FIXTURE = Object.freeze([ + Object.freeze({ + active: true, + age: "any", + emotions: [ + Object.freeze({ active: true, emotion: "calm", emotionLabel: "Calm", id: "calm", pitch: 1, rate: 1, volume: 1 }), + Object.freeze({ active: true, emotion: "urgent", emotionLabel: "Urgent", id: "urgent", pitch: 1.08, rate: 1.15, volume: 1 }), + ], + gender: "neutral", + id: "default-balanced-profile", + language: "en-US", + name: "Default Balanced Profile", + providerKey: "browser-speech", + voice: "Browser default", + voiceName: "Default browser voice", + }), + Object.freeze({ + active: true, + age: "adult", + emotions: [ + Object.freeze({ active: true, emotion: "neutral", emotionLabel: "Neutral", id: "neutral", pitch: 1, rate: 1, volume: 1 }), + Object.freeze({ active: true, emotion: "calm", emotionLabel: "Calm", id: "calm", pitch: 1, rate: 1, volume: 1 }), + Object.freeze({ active: true, emotion: "urgent", emotionLabel: "Urgent", id: "urgent", pitch: 1.08, rate: 1.15, volume: 1 }), + ], + gender: "male", + id: "man-profile-1", + language: "en-US", + name: "Man Profile 1", + providerKey: "browser-speech", + voice: "Browser default", + voiceName: "Default browser voice", + }), + Object.freeze({ + active: true, + age: "adult", + emotions: [ + Object.freeze({ active: true, emotion: "whisper", emotionLabel: "Whisper", id: "whisper", pitch: 0.95, rate: 0.9, volume: 0.55 }), + Object.freeze({ active: true, emotion: "robot", emotionLabel: "Robot", id: "robot", pitch: 0.82, rate: 0.92, volume: 0.9 }), + ], + gender: "female", + id: "woman-profile-2", + language: "en-US", + name: "Woman Profile 2", + providerKey: "browser-speech", + voice: "Browser default", + voiceName: "Default browser voice", + }), +]); async function jsonRequest(url, options = {}) { const response = await fetch(url, { @@ -111,6 +160,18 @@ async function openMessagesPage(page, options = {}) { }, }; }); + if (options.seedSavedTtsProfiles !== false) { + await page.addInitScript(({ profiles, storageKey }) => { + window.localStorage?.setItem(storageKey, JSON.stringify({ + profiles, + updatedAt: "2026-06-23T00:00:00.000Z", + version: "playwright-fixture", + })); + }, { + profiles: options.savedTtsProfiles || SAVED_TTS_PROFILES_FIXTURE, + storageKey: TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, + }); + } await workspaceV2CoverageReporter.start(page); await page.goto(`${server.baseUrl}/tools/messages/index.html`, { waitUntil: "networkidle" }); return failures; @@ -161,6 +222,14 @@ async function expectPlaybackDiagnostics(page, { await expect(log).toContainText(`Age Filter: ${ageFilter}`); } +async function setRangeValue(locator, value) { + await locator.evaluate((input, nextValue) => { + input.value = nextValue; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, String(value)); +} + async function createdMessage(server, name) { const listResult = await jsonRequest(`${server.baseUrl}/api/messages/messages`); expect(listResult.response.ok).toBe(true); @@ -500,7 +569,6 @@ test("Message Studio uses the approved table-first Messages structure", async ({ const manProfile = (await voiceProfiles(failures.server)).find((profile) => profile.name === "Man Profile 1"); expect(manProfile).toEqual(expect.objectContaining({ - gender: "male", language: "en-US", providerKey: "browser-speech", voiceName: "Default browser voice", @@ -519,6 +587,95 @@ test("Message Studio uses the approved table-first Messages structure", async ({ } }); +test("Message Studio consumes active saved Text To Speech profiles", async ({ page }) => { + const failures = await openMessagesPage(page, { seedSavedTtsProfiles: false }); + + try { + await page.goto(`${failures.server.baseUrl}/tools/text-to-speech/index.html`, { waitUntil: "networkidle" }); + await expect(page.getByRole("heading", { level: 1, name: "Text To Speech" })).toBeVisible(); + + await page.locator("[data-tts-profile-add-control-row]").getByRole("button", { name: "Add Profile" }).click(); + await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Quest Profile Draft"); + await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("male"); + await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-age]").selectOption("adult"); + await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-voice]").selectOption("Browser guide updated"); + await page.locator("[data-tts-commit-profile='__new__']").click(); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Quest Profile Draft."); + + await page.locator("[data-tts-profile-row]").filter({ hasText: "Quest Profile Draft" }).getByRole("button", { name: "Edit Profile" }).click(); + await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Quest Profile Active"); + await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Quest Profile Active."); + + await page.locator("[data-tts-emotion-add-control-row]").getByRole("button", { name: "Add Emotion" }).click(); + await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); + await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]"), "1.2"); + await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]"), "1.1"); + await setRangeValue(page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]"), "0.7"); + await page.locator("[data-tts-commit-emotion='__new__']").click(); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion: Urgent."); + + await page.goto(`${failures.server.baseUrl}/tools/messages/index.html`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add Message" }).click(); + await expect(page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]")).toContainText("Quest Profile Active"); + await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill("Quest Profile Message"); + await page.locator("[data-messages-row-editor='__new__'] [data-message-tts-profile]").selectOption({ label: "Quest Profile Active" }); + await page.locator("[data-messages-commit='__new__']").click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved message Quest Profile Message."); + + const messageRow = await ensureSentencesExpanded(page, "Quest Profile Message"); + await page.getByRole("button", { name: "Add Sentence" }).click(); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion] option")).toHaveText([ + "Select emotion", + "Neutral", + "Urgent", + ]); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Happy"); + await expect(page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]")).not.toContainText("Scared"); + await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill("1"); + await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill("The quest door opens."); + await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: "Urgent" }); + await page.locator("[data-messages-segment-commit='__new__']").click(); + await expect(page.locator("[data-messages-log]")).toHaveText("Saved sentence 1."); + + const sentenceRow = page.locator("[data-messages-segment-row]").filter({ hasText: "The quest door opens." }); + await page.evaluate(() => { + window.__spokenUtterances = []; + }); + await sentenceRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); + let utterances = await page.evaluate(() => window.__spokenUtterances); + expect(utterances).toEqual([expect.objectContaining({ + pitch: 1.2, + rate: 1.1, + text: "The quest door opens.", + volume: 0.7, + })]); + await expectPlaybackDiagnostics(page, { + ageFilter: "Adult", + gender: "Male", + profile: "Quest Profile Active", + voice: "Browser guide updated", + }); + + await page.evaluate(() => { + window.__spokenUtterances = []; + }); + await messageRow.getByRole("button", { name: "Play" }).click(); + await page.waitForFunction(() => window.__spokenUtterances.length === 1); + utterances = await page.evaluate(() => window.__spokenUtterances); + expect(utterances.map((utterance) => utterance.text)).toEqual(["The quest door opens."]); + await expect(page.locator("[data-messages-validation-errors]")).not.toContainText(/before preview/i); + await expect(page.locator("[data-messages-log]")).not.toContainText(/before preview/i); + + expect(failures.failedRequests).toEqual([]); + expect(failures.pageErrors).toEqual([]); + expect(failures.consoleErrors).toEqual([]); + } finally { + await closeMessagesRun(failures, page); + } +}); + test("Message Studio loads Text To Speech profiles and filters sentence emotions by selected profile", async ({ page }) => { const failures = await openMessagesPage(page); diff --git a/tests/tools/MessagesPlaybackSource.test.mjs b/tests/tools/MessagesPlaybackSource.test.mjs index b0389d19a..5ee7607c0 100644 --- a/tests/tools/MessagesPlaybackSource.test.mjs +++ b/tests/tools/MessagesPlaybackSource.test.mjs @@ -20,9 +20,19 @@ test("Messages sentence emotion picker does not fall back to unrelated global em test("Messages wires profile dropdowns through the Text To Speech profile contract", async () => { const source = await readFile(new URL("../../toolbox/messages/messages.js", import.meta.url), "utf8"); - assert.equal(source.includes("../text-to-speech/text2speech.js"), true); - assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), true); - assert.equal(source.includes("createMessageStudioTtsProfileOptions"), true); + assert.equal(source.includes("../text-to-speech/text2speech.js"), false); + assert.equal(source.includes("../text-to-speech/tts-profile-store.js"), true); + assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), false); + assert.equal(source.includes("createMessageStudioTtsProfileOptions"), false); assert.equal(source.includes("state.voiceProfiles = voicePayload.ttsProfiles || []"), false); - assert.equal(source.includes("messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || [])"), true); + assert.equal(source.includes("messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || [])"), false); + assert.equal(source.includes("activeTextToSpeechProfilesForMessages(voicePayload.ttsProfiles || [])"), true); +}); + +test("Messages dev runtime does not import browser Text To Speech UI modules", async () => { + const source = await readFile(new URL("../../src/dev-runtime/messages/messages-postgres-service.mjs", import.meta.url), "utf8"); + + assert.equal(source.includes("toolbox/text-to-speech/text2speech.js"), false); + assert.equal(source.includes("createMessageStudioDefaultTtsProfiles"), false); + assert.equal(source.includes("createMessageStudioTtsProfileOptions"), false); }); diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs index eea35e2cb..f857327f9 100644 --- a/tests/tools/Text2SpeechShell.test.mjs +++ b/tests/tools/Text2SpeechShell.test.mjs @@ -7,8 +7,6 @@ import { TTS_PROVIDER_ADAPTER_PLAN, createDefaultTextToSpeechProfiles, createEmotionProfile, - createMessageStudioDefaultTtsProfiles, - createMessageStudioTtsProfileOptions, createSpeechPreviewRequest, createTextToSpeechProfile, createTextToSpeechProfileEmotion, @@ -16,6 +14,12 @@ import { createVoiceProfile, previewTtsMessage, } from "../../toolbox/text-to-speech/text2speech.js"; +import { + TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, + readSavedTextToSpeechProfiles, + textToSpeechProfilesToMessageOptions, + writeSavedTextToSpeechProfiles, +} from "../../toolbox/text-to-speech/tts-profile-store.js"; test("Text2Speech message model separates Design and Audio ownership", () => { const message = createTtsMessage({ text: "Hello", metadata: { tags: ["intro"] } }); @@ -66,10 +70,9 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai assert.ok(TTS_PROVIDER_ADAPTER_PLAN.slice(1).every((provider) => provider.status === "planned")); }); -test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { +test("Text2Speech saved profile store exposes active profiles for Messages", () => { const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; const defaults = createDefaultTextToSpeechProfiles(voiceOptions); - const messageStudioDefaults = createMessageStudioDefaultTtsProfiles(voiceOptions); const custom = createTextToSpeechProfile({ emotions: [ createTextToSpeechProfileEmotion({ @@ -85,22 +88,34 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt voice: "test-voice", voiceName: "Test Voice", }); - const options = createMessageStudioTtsProfileOptions([custom]); + const writes = new Map(); + const storage = { + getItem(key) { + return writes.get(key) || ""; + }, + setItem(key, value) { + writes.set(key, value); + }, + }; + + assert.equal(writeSavedTextToSpeechProfiles([custom], storage), true); + const savedProfiles = readSavedTextToSpeechProfiles(storage); + const options = textToSpeechProfilesToMessageOptions(savedProfiles); assert.equal(TTS_PROFILE_CONTRACT_VERSION, "tts-profile-emotion-v1"); + assert.equal(writes.has(TEXT_TO_SPEECH_PROFILE_STORAGE_KEY), true); assert.equal(defaults[0].name, "Default Balanced Profile"); assert.equal(defaults[0].messageStudioUsageCount, 1); assert.deepEqual(defaults[0].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); assert.deepEqual(defaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); assert.deepEqual(defaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Happy", "Angry", "Scared"]); - assert.deepEqual(messageStudioDefaults[1].emotions.map((emotion) => emotion.emotionLabel), ["Neutral", "Calm", "Urgent"]); - assert.deepEqual(messageStudioDefaults[2].emotions.map((emotion) => emotion.emotionLabel), ["Whisper", "Robot"]); assert.equal(defaults[0].emotions.find((emotion) => emotion.emotion === "neutral").messagePartsUsageCount, 1); assert.deepEqual(options, [{ active: true, age: "any", ageFilter: "any", emotionSettings: [{ + active: true, emotion: "urgent", emotionLabel: "Urgent", key: "urgent", @@ -114,6 +129,7 @@ test("Text2Speech profile contract exposes Message Studio compatible profile opt language: "en-US", name: "Custom Profile", providerKey: "browser-speech", + sourceProfileId: "custom-profile", voice: "test-voice", voiceName: "Test Voice", }]); diff --git a/toolbox/messages/messages.js b/toolbox/messages/messages.js index 72c339f58..6eb31f6f1 100644 --- a/toolbox/messages/messages.js +++ b/toolbox/messages/messages.js @@ -1,10 +1,12 @@ import { - createMessageStudioDefaultTtsProfiles, - createMessageStudioTtsProfileOptions, -} from "../text-to-speech/text2speech.js"; + readSavedTextToSpeechProfiles, + textToSpeechProfilesToMessageOptions, +} from "../text-to-speech/tts-profile-store.js"; import { + createEmotionProfile, createMessage, createMessageSegment, + createTtsProfile, deleteMessage, deleteMessageSegment, listEmotionProfiles, @@ -25,11 +27,6 @@ const TTS_PROVIDER_REGISTRY = Object.freeze({ "openai": Object.freeze({ activeRuntime: false, label: "OpenAI", requiresConfig: true }), "polly": Object.freeze({ activeRuntime: false, label: "Polly", requiresConfig: true }), }); -const MESSAGE_STUDIO_TTS_PROFILE_CONTRACT = Object.freeze(createMessageStudioTtsProfileOptions(createMessageStudioDefaultTtsProfiles()) - .map((profile) => Object.freeze({ - ...profile, - emotionSettings: Object.freeze(profile.emotionSettings.map((setting) => Object.freeze({ ...setting }))), - }))); const elements = { addMessage: document.querySelector("[data-messages-add-row]"), @@ -203,47 +200,99 @@ function normalizedLookupKey(value) { return String(value || "").trim().toLowerCase(); } -function apiTtsProfileByContractName(apiProfiles = []) { +function apiTtsProfileByName(apiProfiles = []) { return new Map(apiProfiles.map((profile) => [normalizedLookupKey(profile.name), profile])); } -function apiEmotionSettingForContract(apiProfile, contractSetting) { - const settings = Array.isArray(apiProfile?.emotionSettings) ? apiProfile.emotionSettings : []; - const label = normalizedLookupKey(contractSetting.emotionLabel || contractSetting.name || contractSetting.emotion); - const emotion = normalizedLookupKey(contractSetting.emotion); - return settings.find((setting) => normalizedLookupKey(setting.emotionLabel || setting.name) === label) - || settings.find((setting) => normalizedLookupKey(setting.emotion) === emotion) - || null; +function apiEmotionProfileByName(apiProfiles = []) { + return new Map(apiProfiles.map((profile) => [normalizedLookupKey(profile.name), profile])); } -function messageStudioTtsProfilesFromContract(apiProfiles = []) { - const apiByName = apiTtsProfileByContractName(apiProfiles); - return MESSAGE_STUDIO_TTS_PROFILE_CONTRACT.map((contractProfile) => { - const apiProfile = apiByName.get(normalizedLookupKey(contractProfile.name)) || null; +function ensureApiTtsProfileForSavedProfile(profile, apiByName) { + const name = profileValue(profile?.name, ""); + if (!name) { + return null; + } + const existing = apiByName.get(normalizedLookupKey(name)) || null; + if (existing) { + return existing; + } + const result = createTtsProfile({ + active: true, + description: `${name} from Text To Speech profile ownership.`, + language: profileValue(profile.language, "en-US"), + name, + pitch: 1, + providerKey: profileValue(profile.providerKey, "browser-speech"), + rate: 1, + voiceName: profileValue(profile.voiceName || profile.voice, "Default browser voice"), + volume: 1, + }); + const created = result.ttsProfile || null; + if (!created) { + throw new Error("Text To Speech profile could not be synced."); + } + apiByName.set(normalizedLookupKey(created.name), created); + return created; +} + +function ensureApiEmotionProfileForSavedEmotion(setting, apiByName) { + const name = profileValue(setting?.emotionLabel || setting?.name || setting?.emotion, ""); + if (!name) { + return null; + } + const existing = apiByName.get(normalizedLookupKey(name)) || null; + if (existing) { + return existing; + } + const result = createEmotionProfile({ + active: true, + description: `${name} from Text To Speech profile ownership.`, + name, + pauseAfterMs: 0, + pauseBeforeMs: 0, + pitch: Number(setting.pitch) || 1, + rate: Number(setting.rate) || 1, + volume: Number.isFinite(Number(setting.volume)) ? Number(setting.volume) : 1, + }); + const created = result.emotionProfile || null; + if (!created) { + throw new Error("Text To Speech emotion could not be synced."); + } + apiByName.set(normalizedLookupKey(created.name), created); + state.emotionProfiles.push(created); + return created; +} + +function activeTextToSpeechProfilesForMessages(apiProfiles = []) { + const savedProfiles = readSavedTextToSpeechProfiles(); + const savedOptions = textToSpeechProfilesToMessageOptions(savedProfiles); + const ttsProfilesByName = apiTtsProfileByName(apiProfiles); + const emotionProfilesByName = apiEmotionProfileByName(state.emotionProfiles); + return savedOptions.map((savedProfile) => { + const apiProfile = ensureApiTtsProfileForSavedProfile(savedProfile, ttsProfilesByName); + if (!apiProfile) { + return null; + } return { - ...contractProfile, - active: apiProfile ? apiProfile.active !== false : contractProfile.active !== false, - age: contractProfile.age || apiProfile?.age || "", - ageFilter: contractProfile.ageFilter || apiProfile?.ageFilter || apiProfile?.age || "", - emotionSettings: contractProfile.emotionSettings - .map((contractSetting) => { - const apiSetting = apiEmotionSettingForContract(apiProfile, contractSetting); - const emotionProfile = emotionProfileByLabel(contractSetting.emotionLabel || apiSetting?.emotionLabel || contractSetting.emotion); + ...savedProfile, + active: apiProfile.active !== false && savedProfile.active !== false, + key: apiProfile.key, + emotionSettings: savedProfile.emotionSettings + .map((setting) => { + const emotionProfile = ensureApiEmotionProfileForSavedEmotion(setting, emotionProfilesByName); + if (!emotionProfile) { + return null; + } return { - ...contractSetting, - active: apiSetting ? apiSetting.active !== false : contractSetting.active !== false, - key: emotionProfile?.key || apiSetting?.key || contractSetting.key, + ...setting, + active: emotionProfile.active !== false && setting.active !== false, + key: emotionProfile.key, }; }) - .filter((setting) => setting.key && setting.active !== false), - gender: contractProfile.gender || apiProfile?.gender || "", - key: apiProfile?.key || contractProfile.key, - language: contractProfile.language || apiProfile?.language || "", - providerKey: contractProfile.providerKey || apiProfile?.providerKey || "browser-speech", - voice: contractProfile.voice || apiProfile?.voice || apiProfile?.voiceName || "", - voiceName: contractProfile.voiceName || apiProfile?.voiceName || "", + .filter((setting) => setting?.key && setting.active !== false), }; - }); + }).filter((profile) => profile?.key); } function activeVoiceProfiles() { @@ -1028,7 +1077,12 @@ async function loadAll() { state.emotionProfiles = emotionPayload.emotionProfiles || []; state.messages = messagesPayload.messages || []; state.segments = segmentsPayload.segments || []; - state.voiceProfiles = messageStudioTtsProfilesFromContract(voicePayload.ttsProfiles || []); + try { + state.voiceProfiles = activeTextToSpeechProfilesForMessages(voicePayload.ttsProfiles || []); + } catch { + state.voiceProfiles = []; + showCreatorSafeFailure("Text To Speech profiles could not be loaded. Save a TTS Profile in Text To Speech, then reload Messages."); + } if (state.selectedMessageKey && !state.messages.some((message) => message.key === state.selectedMessageKey)) { state.selectedMessageKey = ""; } @@ -1036,6 +1090,10 @@ async function loadAll() { state.selectedSegmentKey = ""; } render(messagesPayload.persistence || emotionPayload.persistence || segmentsPayload.persistence || voicePayload.persistence); + if (!state.voiceProfiles.length) { + setText(elements.log, "Message Studio loaded. Save a TTS Profile in Text To Speech before adding playback."); + return; + } setText(elements.log, "Message Studio loaded."); } diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js index d98085336..081fa25b2 100644 --- a/toolbox/text-to-speech/text2speech.js +++ b/toolbox/text-to-speech/text2speech.js @@ -10,6 +10,10 @@ import { TEXT_TO_SPEECH_RANGE_DEFAULTS, TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS } from "../../src/engine/audio/TextToSpeechDefaults.js"; +import { + readSavedTextToSpeechProfiles, + writeSavedTextToSpeechProfiles, +} from "./tts-profile-store.js"; const TTS_OWNERSHIP = Object.freeze({ DESIGN: "Design", @@ -280,66 +284,6 @@ function createDefaultTextToSpeechProfiles(voiceOptions = []) { ]; } -function createMessageStudioDefaultTtsProfiles(voiceOptions = []) { - const [balancedProfile, manProfile, womanProfile] = createDefaultTextToSpeechProfiles(voiceOptions); - const withStudioEmotions = (profile, emotions) => createTextToSpeechProfile({ - active: profile.active, - age: profile.age, - emotions, - gender: profile.gender, - id: profile.id, - language: profile.language, - messageStudioUsageCount: profile.messageStudioUsageCount, - name: profile.name, - voice: profile.voice, - voiceName: profile.voiceName - }); - - return [ - withStudioEmotions(balancedProfile, [ - createTextToSpeechProfileEmotion({ emotion: "calm", messagePartsUsageCount: 1 }), - createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), - ]), - withStudioEmotions(manProfile, [ - createTextToSpeechProfileEmotion({ emotion: "neutral" }), - createTextToSpeechProfileEmotion({ emotion: "calm" }), - createTextToSpeechProfileEmotion({ emotion: "urgent", pitch: 1.08, rate: 1.15 }), - ]), - withStudioEmotions(womanProfile, [ - createTextToSpeechProfileEmotion({ emotion: "whisper", pitch: 0.95, rate: 0.9, volume: 0.55 }), - createTextToSpeechProfileEmotion({ emotion: "robot", pitch: 0.82, rate: 0.92, volume: 0.9 }), - ]) - ]; -} - -function createMessageStudioTtsProfileOptions(profiles = []) { - return profiles - .filter((profile) => profile?.active !== false) - .map((profile) => ({ - active: true, - age: profile.age, - ageFilter: profile.age, - emotionSettings: Array.isArray(profile.emotions) - ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ - emotion: emotion.emotion, - emotionLabel: emotion.emotionLabel, - key: emotion.id, - pitch: emotion.pitch, - rate: emotion.rate, - ssmlLikePreset: emotion.ssmlLikePreset, - volume: emotion.volume - })) - : [], - gender: profile.gender, - key: profile.id, - language: profile.language, - name: profile.name, - providerKey: profile.providerKey || "browser-speech", - voice: profile.voice, - voiceName: profile.voiceName || profile.voice || "" - })); -} - function createSpeechPreviewRequest({ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, rate = TEXT_TO_SPEECH_DEFAULTS.rate, @@ -817,6 +761,14 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech return errors; } + function persistProfilesForMessages() { + try { + writeSavedTextToSpeechProfiles(state.profiles); + } catch { + writeStatus("Text To Speech profiles were saved for this tool but could not be shared with Messages. Try again after refreshing the browser.", "FAIL"); + } + } + function emotionValues(key) { const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); const existing = selectedProfile()?.emotions.find((emotion) => emotion.id === key) || null; @@ -874,6 +826,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech state.selectedProfileId = profile.id; state.selectedEmotionId = previewEmotion(profile)?.id || ""; state.editingProfileId = ""; + persistProfilesForMessages(); renderProfileRows(); refreshActionState(); writeStatus(`Saved TTS profile: ${profile.name}.`); @@ -889,6 +842,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech state.profiles = state.profiles.filter((candidate) => candidate.id !== key); if (state.selectedProfileId === key) state.selectedProfileId = state.profiles[0]?.id || ""; if (!state.selectedProfileId) state.selectedEmotionId = ""; + persistProfilesForMessages(); renderProfileRows(); refreshActionState(); writeStatus(`Deleted TTS profile: ${profile.name}.`); @@ -923,6 +877,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech } state.editingEmotionId = ""; state.selectedEmotionId = emotion.id; + persistProfilesForMessages(); renderProfileRows(); refreshActionState(); writeStatus(`Saved emotion: ${emotion.emotionLabel}.`); @@ -938,6 +893,7 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech } profile.emotions = profile.emotions.filter((candidate) => candidate.id !== key); if (state.selectedEmotionId === key) state.selectedEmotionId = previewEmotion(profile)?.id || ""; + persistProfilesForMessages(); renderProfileRows(); refreshActionState(); writeStatus(`Deleted emotion: ${emotion.emotionLabel}.`); @@ -970,9 +926,23 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech if (state.profiles.length) { return; } + try { + const savedProfiles = readSavedTextToSpeechProfiles(); + if (savedProfiles.length) { + state.profiles = savedProfiles; + state.selectedProfileId = ""; + state.selectedEmotionId = ""; + renderProfileRows(); + refreshActionState(); + return; + } + } catch { + writeStatus("Saved Text To Speech profiles could not be loaded. Default profiles are available for this session.", "FAIL"); + } state.profiles = createDefaultTextToSpeechProfiles(state.voiceOptions); state.selectedProfileId = ""; state.selectedEmotionId = ""; + persistProfilesForMessages(); renderProfileRows(); refreshActionState(); } @@ -1255,8 +1225,6 @@ export { TTS_PROVIDER_ADAPTER_PLAN, createEmotionProfile, createDefaultTextToSpeechProfiles, - createMessageStudioDefaultTtsProfiles, - createMessageStudioTtsProfileOptions, createSpeechPreviewRequest, createTextToSpeechProfile, createTextToSpeechProfileEmotion, diff --git a/toolbox/text-to-speech/tts-profile-store.js b/toolbox/text-to-speech/tts-profile-store.js new file mode 100644 index 000000000..268854662 --- /dev/null +++ b/toolbox/text-to-speech/tts-profile-store.js @@ -0,0 +1,160 @@ +const TEXT_TO_SPEECH_PROFILE_STORAGE_KEY = "gamefoundry.textToSpeech.profiles.v1"; +const TEXT_TO_SPEECH_PROFILE_STORE_VERSION = "tts-profile-store-v1"; + +const DEFAULT_LANGUAGE = "en-US"; +const DEFAULT_PROVIDER_KEY = "browser-speech"; +const DEFAULT_VOICE_AGE = "adult"; + +function clampNumber(value, fallback, min, max) { + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + return fallback; + } + return Math.min(max, Math.max(min, numberValue)); +} + +function normalizedText(value, fallback = "") { + const text = String(value || "").trim(); + return text || fallback; +} + +function slugFromText(value, fallback = "item") { + return normalizedText(value, fallback) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || fallback; +} + +function labelFromSlug(value, fallback = "Neutral") { + return normalizedText(value, fallback) + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function defaultStorage() { + try { + return typeof window === "undefined" ? null : window.localStorage; + } catch { + return null; + } +} + +function storagePayloadProfiles(payload) { + if (Array.isArray(payload)) { + return payload; + } + if (Array.isArray(payload?.profiles)) { + return payload.profiles; + } + return []; +} + +function normalizeSavedEmotion(emotion = {}) { + const emotionKey = slugFromText(emotion.emotion || emotion.id || emotion.emotionLabel, "neutral"); + const emotionLabel = normalizedText(emotion.emotionLabel || emotion.name, labelFromSlug(emotionKey)); + return { + active: emotion.active !== false, + emotion: emotionKey, + emotionLabel, + id: normalizedText(emotion.id, emotionKey), + messagePartsUsageCount: Math.max(0, Number(emotion.messagePartsUsageCount) || 0), + pitch: clampNumber(emotion.pitch, 1, 0.1, 2), + rate: clampNumber(emotion.rate, 1, 0.1, 2), + ssmlLikePreset: normalizedText(emotion.ssmlLikePreset, "normal"), + volume: clampNumber(emotion.volume, 1, 0, 1), + }; +} + +function normalizeSavedProfile(profile = {}) { + const name = normalizedText(profile.name, "Default Balanced Profile"); + const emotions = Array.isArray(profile.emotions) && profile.emotions.length + ? profile.emotions.map(normalizeSavedEmotion) + : [normalizeSavedEmotion()]; + return { + active: profile.active !== false, + age: normalizedText(profile.age, DEFAULT_VOICE_AGE), + emotions, + gender: normalizedText(profile.gender, "neutral"), + id: normalizedText(profile.id, slugFromText(name, "tts-profile")), + language: normalizedText(profile.language, DEFAULT_LANGUAGE), + messageStudioUsageCount: Math.max(0, Number(profile.messageStudioUsageCount) || 0), + name, + owner: "Audio", + providerKey: normalizedText(profile.providerKey, DEFAULT_PROVIDER_KEY), + voice: normalizedText(profile.voice), + voiceName: normalizedText(profile.voiceName || profile.voice, "Default browser voice"), + }; +} + +function normalizeSavedTextToSpeechProfiles(profiles = []) { + return Array.isArray(profiles) ? profiles.map(normalizeSavedProfile) : []; +} + +function readSavedTextToSpeechProfiles(storage = defaultStorage()) { + if (!storage || typeof storage.getItem !== "function") { + return []; + } + const raw = storage.getItem(TEXT_TO_SPEECH_PROFILE_STORAGE_KEY); + if (!raw) { + return []; + } + let payload; + try { + payload = JSON.parse(raw); + } catch { + throw new Error("Saved Text To Speech profiles could not be read."); + } + return normalizeSavedTextToSpeechProfiles(storagePayloadProfiles(payload)); +} + +function writeSavedTextToSpeechProfiles(profiles = [], storage = defaultStorage()) { + if (!storage || typeof storage.setItem !== "function") { + return false; + } + const payload = { + profiles: normalizeSavedTextToSpeechProfiles(profiles), + updatedAt: new Date().toISOString(), + version: TEXT_TO_SPEECH_PROFILE_STORE_VERSION, + }; + storage.setItem(TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, JSON.stringify(payload)); + return true; +} + +function textToSpeechProfilesToMessageOptions(profiles = []) { + return normalizeSavedTextToSpeechProfiles(profiles) + .filter((profile) => profile.active !== false) + .map((profile) => ({ + active: true, + age: profile.age, + ageFilter: profile.age, + emotionSettings: profile.emotions + .filter((emotion) => emotion.active !== false) + .map((emotion) => ({ + active: true, + emotion: emotion.emotion, + emotionLabel: emotion.emotionLabel, + key: emotion.id, + pitch: emotion.pitch, + rate: emotion.rate, + ssmlLikePreset: emotion.ssmlLikePreset, + volume: emotion.volume, + })), + gender: profile.gender, + key: profile.id, + language: profile.language, + name: profile.name, + providerKey: profile.providerKey, + sourceProfileId: profile.id, + voice: profile.voice, + voiceName: profile.voiceName || profile.voice || "Default browser voice", + })); +} + +export { + TEXT_TO_SPEECH_PROFILE_STORAGE_KEY, + TEXT_TO_SPEECH_PROFILE_STORE_VERSION, + normalizeSavedTextToSpeechProfiles, + readSavedTextToSpeechProfiles, + textToSpeechProfilesToMessageOptions, + writeSavedTextToSpeechProfiles, +}; From 2c7ddadb0994f49a7bdbb51b55e079a86759a593 Mon Sep 17 00:00:00 2001 From: DavidQ Date: Tue, 23 Jun 2026 19:56:58 -0400 Subject: [PATCH 4/4] Update gitignore for Bravo messages work --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index dcf729395..ec5f65503 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ docs/dev/reports/playwright_v8_coverage_report.txt # Game Foundry Studio project files assets/*.gfsp +docs_build/dev/ProjectInstructions.zip