From a8177711f4106d7fe6af77580ff856229a8764ae Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 3 Oct 2026 22:29:42 +0200 Subject: [PATCH] feat(characters): global cast view in the sidebar and per-book characters inside Studio Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + Manual.md | 9 ++ static/js/library-characters.js | 145 +++++++++++++++++++++++++++--- static/js/studio.js | 60 +++++++++++-- static/sections/s-caststudio.html | 1 + static/style.css | 26 ++++++ tests/characters-scope.spec.js | 115 ++++++++++++++++++++++++ 7 files changed, 336 insertions(+), 22 deletions(-) create mode 100644 tests/characters-scope.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f88a3a9..1622361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- ## [Unreleased] +### Changed +- **Characters: global cast view in the sidebar, per-book characters inside Studio.** *Library > Characters / Cast* is now titled "All characters (all books)": one collapsible band per book (heading, per-book count, `aria-expanded`), first book open and the rest closed and not rendered until opened (DOM budget), and a *Search all books* field. Studio > Voices lists only the book picked in *Source*, under a "Book: " chip with **Show characters of all books**; the Characters step shows the same chip; with no book picked Voices shows a prompt (Pick a book / show all books). Per-book actions stay in the band headers. Tests: `tests/characters-scope.spec.js`. --- diff --git a/Manual.md b/Manual.md index 5915b52..561f661 100644 --- a/Manual.md +++ b/Manual.md @@ -199,6 +199,15 @@ locally against your own database; nothing is sent anywhere. --- +### Characters: all books or one book + +**Purpose:** two views of the same character library, so you always know which book you are working in. + +- **All characters (all books)** (sidebar *Library > Characters / Cast*, or the Library *Cast* tab): every character of every book, one band per book with a heading and a count. The first book is open, the others are closed and only drawn when opened. *Search all books* (name, alias, archetype, book) opens every band with matches and shows "2 of 9". Cards/Table, Sort and every per-book action stay in each band header. +- **Studio > Voices** (step 3): after you pick a book in *Source*, only that book's characters are shown, under a **Book: <title>** chip. **Show characters of all books** leads to the global view. The Characters step (2) shows the same chip. With no book picked, Voices offers **Pick a book** and the link to the global view instead of listing every book. + +**Errors:** if the book title cannot be read, the prompt to pick a book is shown. **Mobile/accessibility:** bands are headings with a show/hide button (`aria-expanded`); the search field is labelled; layouts fit 320 px. **Offline/privacy:** nothing new is sent; the view reads the local library. + ## Magazines ### Purpose diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 5a75d8e..d8f17dd 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -42,15 +42,37 @@ async function _libReviewVisualPairs(button, container) { } } -async function libraryRenderCharacters() { +let _libCharsCache = null; + +// Studio hands this view a single book (Voices step); the sidebar/Library entry +// shows every book. The view is "scoped" exactly while #lib-chars-list is +// borrowed into Studio's Voices slot, so leaving Studio always restores the +// global view without any flag to reset. +function _libShowAllBooks() { + if (typeof window.studioShowAllCharacters === 'function') window.studioShowAllCharacters(); + else if (typeof window.navLibraryView === 'function') window.navLibraryView('characters'); +} + +function _libCharHaystack(rec) { + const sh = rec.sheet || {}; + const parts = [rec.name, rec.form_name, rec.book, sh.archetype, sh.species, sh.real_name, sh.profession]; + ['aliases', 'alias', 'also_known_as'].forEach(function (k) { + const v = sh[k]; + if (Array.isArray(v)) parts.push(v.join(' ')); else if (v) parts.push(v); + }); + return parts.filter(function (x) { return typeof x === 'string' || typeof x === 'number'; }).join(' ').toLowerCase(); +} + +async function libraryRenderCharacters(opts) { const container = document.getElementById('lib-chars-list'); if (!container) return; + const useCache = !!(opts && opts.cached === true && _libCharsCache); const renderSeq = ++_libCharsRenderSeq; // This page can open before the shared voice library has finished loading. // Ratings live in window._voices, so rendering during that gap makes every // assigned voice look unrated even when its .meta.json still has a rating. - if ((!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') { + if (!useCache && (!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') { try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); } if (renderSeq !== _libCharsRenderSeq) return; } @@ -62,13 +84,15 @@ async function libraryRenderCharacters() { // while a bulk voice-design run elsewhere was mid-restart. Leave whatever // is already on screen alone and let the next real refresh fix it. let all = []; - try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } + if (useCache) all = _libCharsCache; + else try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } catch (e) { console.warn('[characters] load failed', e); if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error'); return; } if (renderSeq !== _libCharsRenderSeq) return; + _libCharsCache = all; // Every action on this page (remove voice, auto-design, delete...) re-runs // this full rebuild via `refresh()`. Collapsing the container down to a @@ -84,7 +108,24 @@ async function libraryRenderCharacters() { const byId = new Map(all.map(function (rec) { return [rec.id, rec]; })); - if (!all.length) { + const inStudio = !!container.closest('#stu-voices-slot'); + const scopeBook = inStudio ? String(window._libCharsStudioBook || '') : ''; + if (inStudio && !scopeBook) { + container.innerHTML = '<div class="lib-chars-scope-prompt card" role="status">' + + '<h3><span class="mdi mdi-book-open-page-variant-outline"></span> Pick a book first</h3>' + + '<p>Voices are assigned per book. Choose one of your uploaded books in the Source step, then come back here to see only its characters.</p>' + + '<div class="lib-chars-scope-actions">' + + '<button type="button" class="btn-primary btn-sm" id="lib-chars-pick-book"><span class="mdi mdi-file-document-outline"></span> Pick a book</button>' + + '<button type="button" class="btn-secondary btn-sm" id="lib-chars-show-all"><span class="mdi mdi-bookshelf"></span> Show characters of all books</button>' + + '</div></div>'; + container.querySelector('#lib-chars-pick-book').addEventListener('click', function () { + if (typeof window.showStudioPhase === 'function') window.showStudioPhase(1); + }); + container.querySelector('#lib-chars-show-all').addEventListener('click', _libShowAllBooks); + return; + } + + if (!all.length && !inStudio) { container.innerHTML = '<div class="lib-chars-toolbar">' + '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>' + '</div>' @@ -108,6 +149,13 @@ async function libraryRenderCharacters() { byBook[bk].push(rec); }); + if (inStudio) { + // Studio is working inside ONE book: drop every other production. + const only = byBook[scopeBook] || []; + Object.keys(byBook).forEach(function (k) { delete byBook[k]; }); + byBook[scopeBook] = only; + } + // The Narrator reads every non-dialogue line but was never a real Library // record (castWriteBack deliberately never creates one, to keep narrators // out of the cast library) — so there was nowhere in Assign Voices to give @@ -188,6 +236,32 @@ async function libraryRenderCharacters() { }); container.appendChild(bar); + const searchQuery = inStudio ? '' : String(window._libCharsQuery || '').trim().toLowerCase(); + const bookNames = Object.keys(byBook).sort(); + const head = document.createElement('div'); + head.className = 'lib-chars-scope'; + if (inStudio) { + head.innerHTML = '<span class="lib-chars-book-chip" id="lib-chars-book-chip"><span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> Book: <b>' + escHtml(scopeBook) + '</b></span>' + + '<button type="button" class="btn-secondary btn-sm" id="lib-chars-show-all"><span class="mdi mdi-bookshelf"></span> Show characters of all books</button>'; + head.querySelector('#lib-chars-show-all').addEventListener('click', _libShowAllBooks); + } else { + head.innerHTML = '<h3 class="lib-chars-global-title" id="lib-chars-global-title"><span class="mdi mdi-account-box-multiple-outline" aria-hidden="true"></span> All characters (all books)</h3>' + + '<span class="lib-chars-global-count" id="lib-chars-global-count">' + all.length + ' characters in ' + bookNames.length + ' book' + (bookNames.length === 1 ? '' : 's') + '</span>' + + '<label class="lib-chars-search"><span class="lib-chars-search-label">Search all books</span>' + + '<input type="search" id="lib-chars-search" placeholder="Name, alias, archetype, book…" spellcheck="false" autocomplete="off" value="' + escHtml(String(window._libCharsQuery || '')) + '"></label>'; + const searchEl = head.querySelector('#lib-chars-search'); + let searchTimer = null; + searchEl.addEventListener('input', function () { + clearTimeout(searchTimer); + searchTimer = setTimeout(function () { + window._libCharsQuery = searchEl.value; + window._libCharsFocusSearch = true; + libraryRenderCharacters({ cached: true }); + }, 180); + }); + } + container.appendChild(head); + const _charSortCmp = { tier: function (a, b) { const tierOrder = { main: 0, supporting: 1, minor: 2 }; @@ -219,7 +293,8 @@ async function libraryRenderCharacters() { const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc'; const productions = document.createDocumentFragment(); - Object.keys(byBook).sort().forEach(function (book) { + let shownBands = 0; + Object.keys(byBook).sort().forEach(function (book, bandIndex) { const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier); if (sortDir === 'desc') chars.reverse(); // Narrator stays pinned first regardless of sort — same convention as @@ -230,13 +305,18 @@ async function libraryRenderCharacters() { // Keep large productions responsive: the complete record set remains in // memory for sorting, relationship lookups and bulk operations, while // only one 50-row page is put into the DOM at a time. + const matched = searchQuery + ? chars.filter(function (r) { return _libCharHaystack(r).indexOf(searchQuery) !== -1; }) + : chars; + if (searchQuery && !matched.length) return; + shownBands++; const pageKey = 'ttsvc_libchars_page::' + book; // Keep every page comfortably below the DOM budget on large casts. The // complete record set remains available for sorting and bulk operations. const pageSize = 25; const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1); - const visibleCount = Math.min(chars.length, requestedPage * pageSize); - const visibleChars = chars.slice(0, visibleCount); + const visibleCount = Math.min(matched.length, requestedPage * pageSize); + const visibleChars = matched.slice(0, visibleCount); const cov = libBookCover(book); const prod = document.createElement('div'); @@ -249,12 +329,22 @@ async function libraryRenderCharacters() { // production shut, so a 40+ character roster from an unrelated book // doesn't bury the one actually being worked on. const collapseKey = 'ttsvc_libchars_collapsed::' + book; - let isCollapsed = localStorage.getItem(collapseKey) === '1'; + // Global view: collapsed unless the user opened it (the first book starts + // open); a search opens every band that has matches. Collapsed bands are + // not rendered at all (lazy) to keep the DOM small; opening one re-renders. + const storedCollapse = localStorage.getItem(collapseKey); + let isCollapsed = storedCollapse === null ? bandIndex > 0 : storedCollapse === '1'; if (window._libCharsScrollToBook) isCollapsed = (book !== window._libCharsScrollToBook); + if (searchQuery) isCollapsed = false; + if (inStudio) isCollapsed = false; + const lazy = isCollapsed; + const bodyId = 'lib-chars-body-' + bandIndex; + if (inStudio) prod.classList.add('lib-chars-prod-scoped'); + prod.dataset.lazy = lazy ? '1' : '0'; if (isCollapsed) prod.classList.add('lib-chars-production-collapsed'); prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">' - + '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production"><span class="mdi mdi-chevron-down"></span></button>' - + '<div class="lib-chars-prod-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>' + + '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '" aria-controls="' + bodyId + '" aria-label="' + escHtml(book) + ' (' + chars.length + ' characters): show or hide"><span class="mdi mdi-chevron-down" aria-hidden="true"></span></button>' + + '<h3 class="lib-chars-prod-title"><span class="mdi mdi-bookshelf" aria-hidden="true"></span> <span class="lib-chars-prod-name">' + escHtml(book) + '</span> <span class="lib-chars-prod-count" title="Characters in this book">' + (searchQuery ? matched.length + ' of ' + chars.length : chars.length) + '</span></h3>' + '<div class="lib-chars-prod-actions">' + '<button class="btn-secondary btn-sm lib-chars-bookctx-btn" data-book="' + escHtml(book) + '" title="Set genre, setting, era and language for this book — gives every voice/image prompt real context instead of guessing per character"><span class="mdi mdi-book-cog-outline"></span> Context</button>' + '<button class="btn-secondary btn-sm lib-chars-casting-btn" data-book="' + escHtml(book) + '" title="Back to the casting script (speaker attribution)"><span class="mdi mdi-drama-masks"></span> Casting</button>' @@ -280,14 +370,14 @@ async function libraryRenderCharacters() { + '<button class="btn-secondary btn-sm lib-chars-bulk-delete-btn" data-book="' + escHtml(book) + '" title="Delete every checked character from the library — use this to clear out stale/corrupted entries before a fresh recast" disabled><span class="mdi mdi-trash-can-outline"></span> Delete selected (<span class="lib-chars-bulk-count-delete">0</span>)</button>' + '<button class="btn-secondary btn-sm lib-chars-fix-lang-btn" data-book="' + escHtml(book) + '" title="Find every character whose current voice does not match this book\'s language, and design a properly-matching one — no selection needed, scans the whole production"><span class="mdi mdi-earth-arrow-right"></span> Fix wrong-language voices</button>' + '</div></div>' - + '<div class="lib-chars-prod-body">' - + (viewMode === 'table' + + '<div class="lib-chars-prod-body" id="' + bodyId + '" role="region" aria-label="' + escHtml(book) + '">' + + (lazy ? '' : viewMode === 'table' ? _charsTableHtml(visibleChars, sortMode, sortDir) : '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">' + visibleChars.map(function (rec) { return _charCardHtml(rec, chars); }).join('') + '</div>') - + (visibleCount < chars.length - ? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + chars.length + ')</button>' + + (!lazy && visibleCount < matched.length + ? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + matched.length + ')</button>' : '') + '</div>'; @@ -298,7 +388,17 @@ async function libraryRenderCharacters() { prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) { e.stopPropagation(); + if (inStudio) return; + if (prod.dataset.lazy === '1') { + // Not rendered yet: remember the choice and rebuild with this band open. + localStorage.setItem(collapseKey, '0'); + window._libCharsFocusBand = book; + libraryRenderCharacters({ cached: true }); + return; + } const collapsed = prod.classList.toggle('lib-chars-production-collapsed'); + prod.querySelector('.lib-chars-prod-collapse-btn').setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + if (collapsed) prod.dataset.lazy = '0'; localStorage.setItem(collapseKey, collapsed ? '1' : '0'); }); // The whole head bar toggles too, not just the small chevron button — @@ -307,6 +407,7 @@ async function libraryRenderCharacters() { // hit area than the chevron alone. prod.querySelector('.lib-chars-prod-head').addEventListener('click', function (e) { if (e.target.closest('button, select, input, a')) return; + if (inStudio) return; prod.querySelector('.lib-chars-prod-collapse-btn').click(); }); @@ -594,6 +695,22 @@ async function libraryRenderCharacters() { productions.appendChild(prod); }); container.appendChild(productions); + if (searchQuery && !shownBands) { + const none = document.createElement('p'); + none.className = 'lib-chars-search-empty'; + none.setAttribute('role', 'status'); + none.textContent = 'No character matches "' + String(window._libCharsQuery || '').trim() + '" in any book.'; + container.appendChild(none); + } + if (window._libCharsFocusSearch) { + window._libCharsFocusSearch = false; + const se = container.querySelector('#lib-chars-search'); + if (se) { se.focus(); try { se.setSelectionRange(se.value.length, se.value.length); } catch (_) {} } + } else if (window._libCharsFocusBand) { + const fb = window._libCharsFocusBand; window._libCharsFocusBand = null; + const fp = [...container.querySelectorAll('.lib-chars-production')].find(function (p) { return p.dataset.book === fb; }); + fp?.querySelector('.lib-chars-prod-collapse-btn')?.focus(); + } if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(container); // Scroll to a specific book's production block — set by callers that land diff --git a/static/js/studio.js b/static/js/studio.js index 512be60..51a38af 100644 --- a/static/js/studio.js +++ b/static/js/studio.js @@ -188,6 +188,51 @@ function _stuInstallNavGuardOnce() { // the function. function _stuCallSuppressingNav(fn) { return fn(); } +// Title of the book Studio is working on ('' when none is picked). +async function _stuResolveBookTitle() { + let title = null; + if (window._audiobook && window._audiobook.bookId) { + try { + const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId)); + if (r.ok) { const doc = await r.json(); title = doc.title || null; } + } catch (_) {} + } + return title || (window.readerState && window.readerState.title) || ''; +} + +// Header chip on the Characters step: which book this step works on. +async function _stuUpdateBookChip() { + const row = document.getElementById('stu-book-chip-row'); + if (!row) return; + const title = await _stuResolveBookTitle(); + row.textContent = ''; + const chip = document.createElement('span'); + chip.className = 'lib-chars-book-chip'; + chip.id = 'stu-book-chip'; + const icon = document.createElement('span'); + icon.className = 'mdi mdi-book-open-page-variant-outline'; + icon.setAttribute('aria-hidden', 'true'); + chip.appendChild(icon); + chip.appendChild(document.createTextNode(title ? ' Book: ' : ' No book picked yet ')); + if (title) { const b = document.createElement('b'); b.textContent = title; chip.appendChild(b); } + row.appendChild(chip); + const mk = function (id, label, fn) { + const btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'btn-secondary btn-sm'; btn.id = id; btn.textContent = label; + btn.addEventListener('click', fn); row.appendChild(btn); + }; + if (!title) mk('stu-pick-book-btn', 'Pick a book', function () { showStudioPhase(1); }); + mk('stu-show-all-chars-btn', 'Show characters of all books', function () { window.studioShowAllCharacters(); }); +} + +// Leave the per-book Studio view for the global cast view (Library > Characters / Cast). +// Studio's nav guard drops internal navTo('s-library') calls, so allow this one. +window.studioShowAllCharacters = function () { + _stuAllowNextNav = true; + if (typeof window.navLibraryView === 'function') window.navLibraryView('characters'); + else if (typeof window.navTo === 'function') window.navTo('s-library'); +}; + // Per-phase borrow wiring — called by showStudioPhase whenever a phase // becomes active. Each case borrows the real subtree that section already // renders into/wires listeners on; no logic is duplicated here. @@ -200,6 +245,7 @@ function _stuEnterPhase(n) { if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks(); }); } else if (n === 2) { + _stuUpdateBookChip(); _stuShowCastView(_stuCastView); } else if (n === 3) { _stuBorrow('lib-chars-list', 'stu-voices-slot'); @@ -214,14 +260,12 @@ function _stuEnterPhase(n) { // whichever book's cast session is active, so resolve the title from // that via the server instead of trusting readerState.title first. (async () => { - let title = null; - if (window._audiobook && window._audiobook.bookId) { - try { - const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId)); - if (r.ok) { const doc = await r.json(); title = doc.title || null; } - } catch (_) {} - } - window._libCharsScrollToBook = title || (window.readerState && readerState.title) || null; + // Studio works inside ONE book: the Voices step lists only that book's + // characters (library-characters.js reads _libCharsStudioBook while the + // list is borrowed into this slot; '' shows the "pick a book" prompt). + const title = await _stuResolveBookTitle(); + window._libCharsStudioBook = title || ''; + window._libCharsScrollToBook = null; _stuCallSuppressingNav(function () { if (typeof window.libraryRender === 'function') window.libraryRender('characters'); }); diff --git a/static/sections/s-caststudio.html b/static/sections/s-caststudio.html index fbee46c..1cd0a84 100644 --- a/static/sections/s-caststudio.html +++ b/static/sections/s-caststudio.html @@ -56,6 +56,7 @@ <!-- ── Phase 2: Characters ─────────────────────────────────────── --> <div class="stu-phase" id="stu-phase-2" hidden> + <div class="stu-book-chip-row" id="stu-book-chip-row" aria-live="polite"></div> <div class="stu-inner-tabs" id="stu-cast-inner-tabs"> <button type="button" class="stu-book-context-btn" id="stu-change-book-btn" title="Choose a different uploaded book"><span class="mdi mdi-book-open-page-variant-outline"></span> Change book</button> <button type="button" class="stu-book-context-btn" id="stu-book-context-btn" title="Edit genre, setting, era and language before identifying characters"><span class="mdi mdi-book-cog-outline"></span> Edit book context</button> diff --git a/static/style.css b/static/style.css index 72f7c0f..d853aad 100644 --- a/static/style.css +++ b/static/style.css @@ -5989,3 +5989,29 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night .mag-suggest summary:focus-visible, .mag-suggest .mag-suggest-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @media (max-width: 480px) { .mag-suggest .mag-suggest-btn { width: 100%; justify-content: center; } } .mag-stale-badge.mag-pill { color: var(--text); background: rgba(217,119,6,.16); border: 1px solid var(--yellow); } + +/* ── Global cast view / per-book Studio scope ───────────────────────────────── */ +.lib-chars-scope, .stu-book-chip-row { display:flex; flex-wrap:wrap; align-items:center; gap:10px 14px; margin:0 0 14px; min-width:0; } +.stu-book-chip-row:empty { display:none; } +.lib-chars-book-chip { + display:inline-flex; align-items:center; gap:6px; max-width:100%; padding:6px 12px; border-radius:999px; + border:1px solid color-mix(in srgb, var(--accent) 55%, var(--border)); + background:color-mix(in srgb, var(--accent) 12%, var(--surface)); color:var(--text); font-size:14px; overflow-wrap:anywhere; +} +.lib-chars-global-title { margin:0; font-size:17px; font-weight:700; color:var(--text); display:flex; align-items:center; gap:8px; } +.lib-chars-global-count { color:var(--subtext); font-size:13px; } +.lib-chars-search { display:flex; flex-direction:column; gap:2px; flex:1 1 220px; min-width:0; max-width:420px; margin-left:auto; } +.lib-chars-search-label { font-size:12px; font-weight:600; color:var(--subtext); } +.lib-chars-search input { width:100%; box-sizing:border-box; min-height:36px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:14px; } +.lib-chars-search input:focus-visible, .lib-chars-prod-collapse-btn:focus-visible { outline:3px solid var(--accent); outline-offset:2px; } +.lib-chars-search-empty { color:var(--subtext); padding:12px 0; } +.lib-chars-prod-title { margin:0; color:inherit; min-width:0; flex:1 1 auto; overflow-wrap:anywhere; } +.lib-chars-prod-name { min-width:0; } +.lib-chars-prod-count { display:inline-block; padding:1px 8px; border-radius:999px; background:rgba(0,0,0,.35); color:#fff; font-size:12px; font-weight:700; } +.lib-chars-prod-scoped .lib-chars-prod-collapse-btn { display:none; } +.lib-chars-prod-scoped .lib-chars-prod-head { cursor:default; } +.lib-chars-scope-prompt { padding:20px; max-width:560px; } +.lib-chars-scope-prompt h3 { margin:0 0 8px; font-size:17px; } +.lib-chars-scope-prompt p { margin:0 0 14px; color:var(--subtext); line-height:1.5; } +.lib-chars-scope-actions { display:flex; flex-wrap:wrap; gap:8px; } +#lib-chars-list { min-width:0; max-width:100%; } diff --git a/tests/characters-scope.spec.js b/tests/characters-scope.spec.js new file mode 100644 index 0000000..83ccbc8 --- /dev/null +++ b/tests/characters-scope.spec.js @@ -0,0 +1,115 @@ +// @ts-check +// Global cast view (sidebar) vs. per-book characters inside Studio. +const { test, expect } = require('@playwright/test'); + +const mk = (book, n, prefix) => Array.from({ length: n }, (_, i) => ({ + id: `${prefix}-${i}`, book, name: `${prefix}Hero${i}`, tags: '', voice: null, + sheet: { gender: i % 2 ? 'F' : 'M', archetype: i === 0 ? 'Rogue' : '' }, +})); +const BOOK_A = 'Alpha Saga 01'; +const BOOK_B = 'Beta Saga 02'; +const BOOK_C = 'Gamma Saga 03'; +const CHARACTERS = [...mk(BOOK_A, 3, 'Al'), ...mk(BOOK_B, 4, 'Be'), ...mk(BOOK_C, 2, 'Ga')]; + +async function setup(page, { resume } = {}) { + await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } })); + await page.route('**/api/voices*', route => route.fulfill({ json: [] })); + await page.route('**/api/settings*', route => route.fulfill({ json: {} })); + await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } })); + await page.route('**/api/reader/docs/book-b', route => route.fulfill({ json: { id: 'book-b', title: BOOK_B, sentences: [] } })); + await page.addInitScript(r => { + try { localStorage.clear(); if (r) localStorage.setItem('ttsvc_studio_resume', JSON.stringify(r)); } catch (_) { /* ignore */ } + }, resume || null); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); +} + +async function openGlobal(page) { + await page.evaluate(() => window.navTo('s-library')); + await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 }); + await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click()); + await expect(page.locator('#lib-chars-global-title')).toHaveText(/All characters \(all books\)/, { timeout: 15_000 }); +} + +test('global cast view groups all books in collapsible, lazily rendered bands', async ({ page }) => { + await setup(page); + await openGlobal(page); + const bands = page.locator('#lib-chars-list .lib-chars-production'); + await expect(bands).toHaveCount(3); + await expect(page.locator('#lib-chars-global-count')).toContainText('9 characters in 3 books'); + await expect(page.locator('#lib-chars-list h3.lib-chars-prod-title')).toHaveCount(3); + // First band open, the others collapsed and not rendered at all. + const first = bands.nth(0); + const second = bands.nth(1); + await expect(first.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true'); + await expect(second.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false'); + await expect(first.locator('.lib-char-card')).toHaveCount(4); // 3 + synthetic Narrator + await expect(second.locator('.lib-char-card')).toHaveCount(0); + await expect(first.locator('.lib-chars-prod-count')).toHaveText('4'); + // Opening a collapsed band renders its characters. + await second.locator('.lib-chars-prod-collapse-btn').click(); + await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-char-card')).toHaveCount(5); + await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true'); + // Collapsing again toggles without a rebuild. + await page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn').click(); + await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); +}); + +test('global cast view searches across all books', async ({ page }) => { + await setup(page); + await openGlobal(page); + const search = page.locator('#lib-chars-search'); + await search.fill('GaHero1'); + const bands = page.locator('#lib-chars-list .lib-chars-production'); + await expect(bands).toHaveCount(1); + await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_C); + await expect(bands.first().locator('.lib-char-card')).toHaveCount(1); + await expect(search).toBeFocused(); + await expect(bands.first().locator('.lib-chars-prod-count')).toHaveText('1 of 3'); + await search.fill('rogue'); // archetype of the first character of every book + await expect(bands).toHaveCount(3); + await search.fill('nobody-here'); + await expect(page.locator('.lib-chars-search-empty')).toBeVisible(); + await search.fill(''); + await expect(bands).toHaveCount(3); +}); + +test('Studio Voices shows only the picked book and links to the global view', async ({ page }) => { + await setup(page, { resume: { phase: 3, bookId: 'book-b', bookTitle: BOOK_B } }); + await page.evaluate(() => window.navTo('s-caststudio')); + await expect(page.locator('#stu-voices-slot #lib-chars-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 }); + const bands = page.locator('#stu-voices-slot .lib-chars-production'); + await expect(bands).toHaveCount(1); + await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_B); + await expect(page.locator('#stu-voices-slot')).not.toContainText(BOOK_A); + await expect(page.locator('#stu-voices-slot .lib-char-card')).toHaveCount(5); + // Per-book actions stay in the band header. + await expect(bands.first().locator('.lib-chars-bulk-voice-btn')).toBeVisible(); + await expect(bands.first().locator('.lib-chars-select-all-btn')).toBeVisible(); + await page.locator('#lib-chars-show-all').click(); + await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 }); + await expect(page.locator('#lib-chars-global-title')).toBeVisible(); + await expect(page.locator('#lib-chars-list .lib-chars-production')).toHaveCount(3); +}); + +test('Studio Characters step shows the book chip', async ({ page }) => { + await setup(page, { resume: { phase: 2, bookId: 'book-b', bookTitle: BOOK_B } }); + await page.evaluate(() => window.navTo('s-caststudio')); + await expect(page.locator('#stu-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 }); + await expect(page.locator('#stu-show-all-chars-btn')).toBeVisible(); +}); + +test('Studio Voices without a picked book offers to pick one instead of listing all books', async ({ page }) => { + await setup(page); + await page.evaluate(() => { window.navTo('s-caststudio'); }); + await page.waitForFunction(() => typeof window.showStudioPhase === 'function', null, { timeout: 15_000 }); + await page.evaluate(() => window.showStudioPhase(3)); + await expect(page.locator('#stu-voices-slot .lib-chars-scope-prompt')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('#stu-voices-slot .lib-chars-production')).toHaveCount(0); + await expect(page.locator('#stu-voices-slot #lib-chars-show-all')).toBeVisible(); + await page.locator('#stu-voices-slot #lib-chars-pick-book').click(); + await expect(page.locator('#stu-phase-1')).toBeVisible(); +});