Compare commits

..

No commits in common. "b90c9ce51a651f44abd6d1e2a505d85adde80881" and "a60a5a92cab32d6232dc42acae36d24391224ec5" have entirely different histories.

13 changed files with 47 additions and 388 deletions

View File

@ -9,12 +9,6 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.59.0] — 2026-10-04
### 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: <title>" 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`.
---
## [1.58.0] — 2026-10-03 ## [1.58.0] — 2026-10-03
### Added ### Added
- **Magazine UI shows page labels, display titles and merge suggestions.** The article index (list, playlist, driving-mode Index sheet and driving header) shows `display_title` (falls back to `title`) with the `page_label` ("Seite 33") in smaller muted text, plus small kind badges (advert, photo, contents, imprint, continuation) and an "Audio is out of date: re-render" badge when `audio_stale` is set. Review shows a suggestion box: the proposed merge target with confidence and a collapsible plain-language "Why do we think so?" list, **Merge into previous article** (`POST .../merge-into-previous {into}`; a 409 "proposal changed" refreshes the suggestion; undoable) and **Use suggested title** (`POST .../accept-title`; undoable). Controls are labelled, keyboard operable and 44 px high. Tests: `tests/magazine.spec.js` ("untitled entries", driving index). - **Magazine UI shows page labels, display titles and merge suggestions.** The article index (list, playlist, driving-mode Index sheet and driving header) shows `display_title` (falls back to `title`) with the `page_label` ("Seite 33") in smaller muted text, plus small kind badges (advert, photo, contents, imprint, continuation) and an "Audio is out of date: re-render" badge when `audio_stale` is set. Review shows a suggestion box: the proposed merge target with confidence and a collapsible plain-language "Why do we think so?" list, **Merge into previous article** (`POST .../merge-into-previous {into}`; a 409 "proposal changed" refreshes the suggestion; undoable) and **Use suggested title** (`POST .../accept-title`; undoable). Controls are labelled, keyboard operable and 44 px high. Tests: `tests/magazine.spec.js` ("untitled entries", driving index).
@ -4708,8 +4702,7 @@ Initial feature-complete release.
--- ---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.59.0...HEAD [Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...HEAD
[1.59.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...v1.59.0
[1.58.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.57.0...v1.58.0 [1.58.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.57.0...v1.58.0
[1.57.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.3...v1.57.0 [1.57.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.3...v1.57.0
[1.56.3]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.2...v1.56.3 [1.56.3]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.2...v1.56.3

View File

@ -199,15 +199,6 @@ 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 ## Magazines
### Purpose ### Purpose

View File

@ -6,11 +6,10 @@ Update in the same commit as the change it describes.
## Current phase ## Current phase
Magazine projects, **P0 and P1 complete** (frontend reachable at Studio → Magazines). Audiobook/Rehearser/voice-library Magazine projects, **P0 and P1 complete** (frontend reachable at Studio → Magazines). Audiobook/Rehearser/voice-library
features are mature and in daily use. Latest release v1.59.0: global "All characters" view in the sidebar and per-book features are mature and in daily use. Latest release v1.58.0: driving-mode menu with switches and theme selector, auto-advance, text fit and text
characters inside Studio, untitled-page cleanup with an exclude option, duplicate/clean tools. v1.58.0 added the amount, article index with page labels/display titles, continuation and untitled-page detection with title and merge
driving-mode menu, auto-advance, article index with page labels, merge suggestions, distinct face profiles, English suggestions, distinct face profiles and English prompt translation for characters, vision-response cache, character
prompt translation, vision-response cache and the review Approve fix. v1.57.0 added driving mode with sentence/word-level record hygiene fixes and the review Approve fix. v1.57.0 added driving mode with sentence/word-level sync via `tts-aligner`.
sync via `tts-aligner`.
## Deployment ## Deployment
@ -34,7 +33,7 @@ Two self-hosted Docker instances, both port 7890:
- **`tts-aligner`** (forced-aligner sidecar, `aligner/`) exists only in the DGX `docker-compose.yml`. The NUC compose - **`tts-aligner`** (forced-aligner sidecar, `aligner/`) exists only in the DGX `docker-compose.yml`. The NUC compose
file deliberately omits it: with `ALIGNER_URL` unset the app quietly falls back to estimated word timing, so the NUC file deliberately omits it: with `ALIGNER_URL` unset the app quietly falls back to estimated word timing, so the NUC
works unchanged. Known limitation: no measured word times on the NUC. works unchanged. Known limitation: no measured word times on the NUC.
- **Rollback** (either host): check out the previous tag (`git checkout v1.58.0`; on the NUC re-rsync that tree), then - **Rollback** (either host): check out the previous tag (`git checkout v1.57.0`; on the NUC re-rsync that tree), then
`docker compose up -d --force-recreate` (rebuild only if `Dockerfile`/`requirements.txt` changed). Stop the aligner `docker compose up -d --force-recreate` (rebuild only if `Dockerfile`/`requirements.txt` changed). Stop the aligner
with `docker compose stop tts-aligner`; `audio/<id>.timings.json` version 2 files stay readable by v1 readers. with `docker compose stop tts-aligner`; `audio/<id>.timings.json` version 2 files stay readable by v1 readers.
@ -98,10 +97,8 @@ which is the writable volume.
**unresolved** — the compose files currently point at ghcr. **unresolved** — the compose files currently point at ghcr.
## Known limitations ## Known limitations
- Live data changes made on the DGX only (not on the NUC, which has its own DB/config): book KONTROLLE removed, 5 duplicate - Live untitled-page cleanup is not yet applied: `scripts/magazine_untitled.py` needs the owner's `--apply`.
characters resolved, untitled-page cleanup applied (backups under `config/backups/`). - `scripts/clean_character_records.py` has only been run as a dry run; 33 duplicate character pairs need owner decisions.
- 18 re-rendered/stale audio articles are pending the owner's voice choice.
- `scripts/clean_character_records.py` was applied for 5 pairs; the 17 remaining duplicate-sheet pairs need owner decisions.
- Audiobook attribution (span mode) is measured, not perfect: on three hand-corrected novels about - Audiobook attribution (span mode) is measured, not perfect: on three hand-corrected novels about
90-92 % of all words end up with the right voice and ~75-80 % of spoken words with the right 90-92 % of all words end up with the right voice and ~75-80 % of spoken words with the right
speaker. The references themselves contain label noise (1-2 points is not meaningful). Open, real speaker. The references themselves contain label noise (1-2 points is not meaningful). Open, real

View File

@ -1 +1 @@
1.59.0 1.58.0

View File

@ -552,13 +552,9 @@ def merge_entries(layout: dict[str, Any], articles: list[dict[str, Any]], source
def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_confidence: float = HIGH_AT, def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_confidence: float = HIGH_AT,
cache: dict[str, Any] | None = None, cache: dict[str, Any] | None = None,
llm_check: Callable[[str, str], bool | None] | None = None, llm_check: Callable[[str, str], bool | None] | None = None,
titler: Callable[[str, str], str | None] | None = None, titler: Callable[[str, str], str | None] | None = None) -> dict[str, Any]:
exclude: Sequence[str] = ()) -> dict[str, Any]:
"""Simulate merges (page order, chains resolve) and titles on a copy. Returns """Simulate merges (page order, chains resolve) and titles on a copy. Returns
{articles, merges, titles, before}. Nothing is written. Entries in ``exclude`` are {articles, merges, titles, before}. Nothing is written."""
left as they are: never merged away into another entry and never retitled (they may still
receive merge-ins from the pages after them)."""
skip = {str(x) for x in exclude}
work = json.loads(json.dumps(list(articles))) work = json.loads(json.dumps(list(articles)))
language = doc_language(layout) language = doc_language(layout)
before = report(layout, articles, cache) before = report(layout, articles, cache)
@ -568,7 +564,7 @@ def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_conf
order = [r["id"] for r in report(layout, work, cache)["entries"]] order = [r["id"] for r in report(layout, work, cache)["entries"]]
for aid in order: for aid in order:
a = next((x for x in work if x["id"] == aid), None) a = next((x for x in work if x["id"] == aid), None)
if a is None or aid in skip: if a is None:
continue continue
s = suggestion_for(_Index(layout, work), a, llm_check) s = suggestion_for(_Index(layout, work), a, llm_check)
if not s or s["confidence"] < min_confidence: if not s or s["confidence"] < min_confidence:
@ -585,7 +581,7 @@ def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_conf
ann = annotate(layout, work, cache, language) ann = annotate(layout, work, cache, language)
for a in work: for a in work:
info = ann[a["id"]] info = ann[a["id"]]
if a["id"] in skip or info.get("title_source") != "suggested": if info.get("title_source") != "suggested":
continue continue
title, source = info["suggested_title"], info["suggested_title_source"] title, source = info["suggested_title"], info["suggested_title_source"]
if titler is not None and source == "sentence": if titler is not None and source == "sentence":

View File

@ -7,7 +7,7 @@ suggestion at or above ``--min-confidence`` (page order, so chains of continuati
into their article), marks merged articles ``audio_stale`` (audio files are kept), and writes into their article), marks merged articles ``audio_stale`` (audio files are kept), and writes
suggested titles into placeholder titles only. Titles someone set are never touched. suggested titles into placeholder titles only. Titles someone set are never touched.
python3 scripts/magazine_untitled.py <doc-id | path/to/doc_dir> [--apply] [--no-llm] [--exclude ID,ID] [--json out.json] python3 scripts/magazine_untitled.py <doc-id | path/to/doc_dir> [--apply] [--no-llm] [--json out.json]
See architecture/magazine-ingestion.md, "Untitled entries". See architecture/magazine-ingestion.md, "Untitled entries".
""" """
@ -76,7 +76,7 @@ def make_chat(timeout: float = 30.0):
return chat return chat
def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None, exclude=()) -> dict: def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None) -> dict:
layout = mag.load_layout(doc_dir) layout = mag.load_layout(doc_dir)
articles = mag.load_articles(doc_dir) articles = mag.load_articles(doc_dir)
if not layout or not articles: if not layout or not articles:
@ -92,7 +92,7 @@ def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None, exclude
return mu.llm_title(chat, text, language) return mu.llm_title(chat, text, language)
result = mu.plan(layout, articles, min_confidence=min_confidence, cache=cache, result = mu.plan(layout, articles, min_confidence=min_confidence, cache=cache,
llm_check=llm_check, titler=titler if chat else None, exclude=exclude) llm_check=llm_check, titler=titler if chat else None)
index = mu._Index(layout, result["articles"]) index = mu._Index(layout, result["articles"])
for t in result["titles"]: for t in result["titles"]:
if t["source"] == "llm": if t["source"] == "llm":
@ -151,15 +151,12 @@ def main(argv: list[str] | None = None) -> int:
mode.add_argument("--apply", action="store_true", help="write changes (backup first)") mode.add_argument("--apply", action="store_true", help="write changes (backup first)")
p.add_argument("--min-confidence", type=float, default=mu.HIGH_AT, p.add_argument("--min-confidence", type=float, default=mu.HIGH_AT,
help=f"merge threshold (default {mu.HIGH_AT})") help=f"merge threshold (default {mu.HIGH_AT})")
p.add_argument("--exclude", action="append", default=[], metavar="ID[,ID...]",
help="leave these article ids as they are (not merged into another entry, no new title); repeatable")
p.add_argument("--no-llm", action="store_true", help="heuristics and deterministic titles only") p.add_argument("--no-llm", action="store_true", help="heuristics and deterministic titles only")
p.add_argument("--json", help="also write the full result as JSON to this path") p.add_argument("--json", help="also write the full result as JSON to this path")
args = p.parse_args(argv) args = p.parse_args(argv)
doc_dir = resolve_doc_dir(args.doc) doc_dir = resolve_doc_dir(args.doc)
chat = None if args.no_llm else make_chat() chat = None if args.no_llm else make_chat()
summary = run(doc_dir, apply=args.apply, min_confidence=args.min_confidence, chat=chat, summary = run(doc_dir, apply=args.apply, min_confidence=args.min_confidence, chat=chat)
exclude=[i.strip() for v in args.exclude for i in v.split(",") if i.strip()])
print_summary(summary) print_summary(summary)
if args.json: if args.json:
Path(args.json).write_text(json.dumps(summary, ensure_ascii=False, indent=1), "utf-8") Path(args.json).write_text(json.dumps(summary, ensure_ascii=False, indent=1), "utf-8")

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.59.0"> <meta name="app-version" content="1.58.0">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.59.0"> <link rel="stylesheet" href="/static/style.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.59.0"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.59.0"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.59.0"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.59.0"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.59.0"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.58.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.59.0"></script> <script src="/static/loader.js?v=1.58.0"></script>
</body> </body>
</html> </html>

View File

@ -42,37 +42,15 @@ async function _libReviewVisualPairs(button, container) {
} }
} }
let _libCharsCache = null; async function libraryRenderCharacters() {
// 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'); const container = document.getElementById('lib-chars-list');
if (!container) return; if (!container) return;
const useCache = !!(opts && opts.cached === true && _libCharsCache);
const renderSeq = ++_libCharsRenderSeq; const renderSeq = ++_libCharsRenderSeq;
// This page can open before the shared voice library has finished loading. // This page can open before the shared voice library has finished loading.
// Ratings live in window._voices, so rendering during that gap makes every // 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. // assigned voice look unrated even when its .meta.json still has a rating.
if (!useCache && (!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') { if ((!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); } try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); }
if (renderSeq !== _libCharsRenderSeq) return; if (renderSeq !== _libCharsRenderSeq) return;
} }
@ -84,15 +62,13 @@ async function libraryRenderCharacters(opts) {
// while a bulk voice-design run elsewhere was mid-restart. Leave whatever // 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. // is already on screen alone and let the next real refresh fix it.
let all = []; let all = [];
if (useCache) all = _libCharsCache; try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; }
else try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; }
catch (e) { catch (e) {
console.warn('[characters] load failed', e); console.warn('[characters] load failed', e);
if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error'); if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error');
return; return;
} }
if (renderSeq !== _libCharsRenderSeq) return; if (renderSeq !== _libCharsRenderSeq) return;
_libCharsCache = all;
// Every action on this page (remove voice, auto-design, delete...) re-runs // Every action on this page (remove voice, auto-design, delete...) re-runs
// this full rebuild via `refresh()`. Collapsing the container down to a // this full rebuild via `refresh()`. Collapsing the container down to a
@ -108,24 +84,7 @@ async function libraryRenderCharacters(opts) {
const byId = new Map(all.map(function (rec) { return [rec.id, rec]; })); const byId = new Map(all.map(function (rec) { return [rec.id, rec]; }));
const inStudio = !!container.closest('#stu-voices-slot'); if (!all.length) {
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">' 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>' + '<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>' + '</div>'
@ -149,13 +108,6 @@ async function libraryRenderCharacters(opts) {
byBook[bk].push(rec); 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 // The Narrator reads every non-dialogue line but was never a real Library
// record (castWriteBack deliberately never creates one, to keep narrators // record (castWriteBack deliberately never creates one, to keep narrators
// out of the cast library) — so there was nowhere in Assign Voices to give // out of the cast library) — so there was nowhere in Assign Voices to give
@ -236,32 +188,6 @@ async function libraryRenderCharacters(opts) {
}); });
container.appendChild(bar); 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 = { const _charSortCmp = {
tier: function (a, b) { tier: function (a, b) {
const tierOrder = { main: 0, supporting: 1, minor: 2 }; const tierOrder = { main: 0, supporting: 1, minor: 2 };
@ -293,8 +219,7 @@ async function libraryRenderCharacters(opts) {
const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc'; const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc';
const productions = document.createDocumentFragment(); const productions = document.createDocumentFragment();
let shownBands = 0; Object.keys(byBook).sort().forEach(function (book) {
Object.keys(byBook).sort().forEach(function (book, bandIndex) {
const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier); const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier);
if (sortDir === 'desc') chars.reverse(); if (sortDir === 'desc') chars.reverse();
// Narrator stays pinned first regardless of sort — same convention as // Narrator stays pinned first regardless of sort — same convention as
@ -305,18 +230,13 @@ async function libraryRenderCharacters(opts) {
// Keep large productions responsive: the complete record set remains in // Keep large productions responsive: the complete record set remains in
// memory for sorting, relationship lookups and bulk operations, while // memory for sorting, relationship lookups and bulk operations, while
// only one 50-row page is put into the DOM at a time. // 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; const pageKey = 'ttsvc_libchars_page::' + book;
// Keep every page comfortably below the DOM budget on large casts. The // Keep every page comfortably below the DOM budget on large casts. The
// complete record set remains available for sorting and bulk operations. // complete record set remains available for sorting and bulk operations.
const pageSize = 25; const pageSize = 25;
const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1); const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1);
const visibleCount = Math.min(matched.length, requestedPage * pageSize); const visibleCount = Math.min(chars.length, requestedPage * pageSize);
const visibleChars = matched.slice(0, visibleCount); const visibleChars = chars.slice(0, visibleCount);
const cov = libBookCover(book); const cov = libBookCover(book);
const prod = document.createElement('div'); const prod = document.createElement('div');
@ -329,22 +249,12 @@ async function libraryRenderCharacters(opts) {
// production shut, so a 40+ character roster from an unrelated book // production shut, so a 40+ character roster from an unrelated book
// doesn't bury the one actually being worked on. // doesn't bury the one actually being worked on.
const collapseKey = 'ttsvc_libchars_collapsed::' + book; const collapseKey = 'ttsvc_libchars_collapsed::' + book;
// Global view: collapsed unless the user opened it (the first book starts let isCollapsed = localStorage.getItem(collapseKey) === '1';
// 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 (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'); if (isCollapsed) prod.classList.add('lib-chars-production-collapsed');
prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">' 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" 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>' + '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production"><span class="mdi mdi-chevron-down"></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-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
+ '<div class="lib-chars-prod-actions">' + '<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-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>' + '<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>'
@ -370,14 +280,14 @@ async function libraryRenderCharacters(opts) {
+ '<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-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>' + '<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></div>'
+ '<div class="lib-chars-prod-body" id="' + bodyId + '" role="region" aria-label="' + escHtml(book) + '">' + '<div class="lib-chars-prod-body">'
+ (lazy ? '' : viewMode === 'table' + (viewMode === 'table'
? _charsTableHtml(visibleChars, sortMode, sortDir) ? _charsTableHtml(visibleChars, sortMode, sortDir)
: '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">' : '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
+ visibleChars.map(function (rec) { return _charCardHtml(rec, chars); }).join('') + visibleChars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
+ '</div>') + '</div>')
+ (!lazy && visibleCount < matched.length + (visibleCount < chars.length
? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + matched.length + ')</button>' ? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + chars.length + ')</button>'
: '') : '')
+ '</div>'; + '</div>';
@ -388,17 +298,7 @@ async function libraryRenderCharacters(opts) {
prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) { prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) {
e.stopPropagation(); 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'); 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'); localStorage.setItem(collapseKey, collapsed ? '1' : '0');
}); });
// The whole head bar toggles too, not just the small chevron button — // The whole head bar toggles too, not just the small chevron button —
@ -407,7 +307,6 @@ async function libraryRenderCharacters(opts) {
// hit area than the chevron alone. // hit area than the chevron alone.
prod.querySelector('.lib-chars-prod-head').addEventListener('click', function (e) { prod.querySelector('.lib-chars-prod-head').addEventListener('click', function (e) {
if (e.target.closest('button, select, input, a')) return; if (e.target.closest('button, select, input, a')) return;
if (inStudio) return;
prod.querySelector('.lib-chars-prod-collapse-btn').click(); prod.querySelector('.lib-chars-prod-collapse-btn').click();
}); });
@ -695,22 +594,6 @@ async function libraryRenderCharacters(opts) {
productions.appendChild(prod); productions.appendChild(prod);
}); });
container.appendChild(productions); 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); if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(container);
// Scroll to a specific book's production block — set by callers that land // Scroll to a specific book's production block — set by callers that land

View File

@ -188,51 +188,6 @@ function _stuInstallNavGuardOnce() {
// the function. // the function.
function _stuCallSuppressingNav(fn) { return fn(); } 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 // Per-phase borrow wiring — called by showStudioPhase whenever a phase
// becomes active. Each case borrows the real subtree that section already // becomes active. Each case borrows the real subtree that section already
// renders into/wires listeners on; no logic is duplicated here. // renders into/wires listeners on; no logic is duplicated here.
@ -245,7 +200,6 @@ function _stuEnterPhase(n) {
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks(); if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
}); });
} else if (n === 2) { } else if (n === 2) {
_stuUpdateBookChip();
_stuShowCastView(_stuCastView); _stuShowCastView(_stuCastView);
} else if (n === 3) { } else if (n === 3) {
_stuBorrow('lib-chars-list', 'stu-voices-slot'); _stuBorrow('lib-chars-list', 'stu-voices-slot');
@ -260,12 +214,14 @@ function _stuEnterPhase(n) {
// whichever book's cast session is active, so resolve the title from // whichever book's cast session is active, so resolve the title from
// that via the server instead of trusting readerState.title first. // that via the server instead of trusting readerState.title first.
(async () => { (async () => {
// Studio works inside ONE book: the Voices step lists only that book's let title = null;
// characters (library-characters.js reads _libCharsStudioBook while the if (window._audiobook && window._audiobook.bookId) {
// list is borrowed into this slot; '' shows the "pick a book" prompt). try {
const title = await _stuResolveBookTitle(); const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
window._libCharsStudioBook = title || ''; if (r.ok) { const doc = await r.json(); title = doc.title || null; }
window._libCharsScrollToBook = null; } catch (_) {}
}
window._libCharsScrollToBook = title || (window.readerState && readerState.title) || null;
_stuCallSuppressingNav(function () { _stuCallSuppressingNav(function () {
if (typeof window.libraryRender === 'function') window.libraryRender('characters'); if (typeof window.libraryRender === 'function') window.libraryRender('characters');
}); });

View File

@ -56,7 +56,6 @@
<!-- ── Phase 2: Characters ─────────────────────────────────────── --> <!-- ── Phase 2: Characters ─────────────────────────────────────── -->
<div class="stu-phase" id="stu-phase-2" hidden> <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"> <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-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> <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>

View File

@ -5989,29 +5989,3 @@ 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; } .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; } } @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); } .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%; }

View File

@ -1,115 +0,0 @@
// @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();
});

View File

@ -316,18 +316,6 @@ class PlanTests(unittest.TestCase):
layout, articles = sample() layout, articles = sample()
self.assertEqual(mu.plan(layout, articles, min_confidence=1.01)["merges"], []) self.assertEqual(mu.plan(layout, articles, min_confidence=1.01)["merges"], [])
def test_excluded_entries_are_left_untouched(self):
layout, articles = sample()
base = mu.plan(layout, articles)
self.assertTrue(base["merges"])
src = base["merges"][0]["source"]
result = mu.plan(layout, articles, exclude=[src])
self.assertTrue(all(m["source"] != src for m in result["merges"]))
self.assertNotIn(src, {t["id"] for t in result["titles"]})
kept = next(a for a in result["articles"] if a["id"] == src)
orig = next(a for a in articles if a["id"] == src)
self.assertEqual(kept["title"], orig["title"])
def test_apply_titles_only_touches_placeholders(self): def test_apply_titles_only_touches_placeholders(self):
_, articles = sample() _, articles = sample()
n = mu.apply_titles(articles, [{"id": "article-001", "after": "X"}, {"id": "article-004", "after": "Anzeige", n = mu.apply_titles(articles, [{"id": "article-001", "after": "X"}, {"id": "article-004", "after": "Anzeige",