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: ** 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 = '
'
+ + '
Pick a book first
'
+ + '
Voices are assigned per book. Choose one of your uploaded books in the Source step, then come back here to see only its characters.
'
@@ -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 = ' Book: ' + escHtml(scopeBook) + ''
+ + '';
+ head.querySelector('#lib-chars-show-all').addEventListener('click', _libShowAllBooks);
+ } else {
+ head.innerHTML = '
All characters (all books)
'
+ + '' + all.length + ' characters in ' + bookNames.length + ' book' + (bookNames.length === 1 ? '' : 's') + ''
+ + '';
+ 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 = '