diff --git a/CHANGELOG.md b/CHANGELOG.md
index befc7ca..a02f914 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### 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).
- **Untitled magazine entries ("Page 33") are explained, merged on request and given readable titles** (`core/magazine_untitled.py`, `scripts/magazine_untitled.py`). Root cause: the review screen creates a `Page N` stand-in for any page no article covers, and *Approve* without a toggled box saved only the visible page's regions, which cut multi-page features down to one page. On *c't KI Wissen 2026* this left 59 stand-ins, most of them continuation pages. A continuation detector scores each untitled entry against the article before it (lower-case/mid-sentence start, hyphenated break, the narration fusion rule, open ending, adjacent page, column width; a signed-off previous article or the page's own headline counts against). An optional local-LLM check runs only for ambiguous scores. Each proposal comes with a confidence and the evidence. Records whose title repeats the headline of the article they continue are proposed as well. Titles: a deterministic fallback (*Anzeige*, *Bildseite*, *Inhalt*, *Impressum*, *Editorial*, *Textfragment*, the page headline, or the first sentence cut to 8 words), plus optional validated and cached LLM titles in the magazine's language (`magazine_llm_titles`, `--no-llm`). Nothing changes silently. `GET /articles` gains derived `display_title`, `title_source`, `suggested_title`, `page_label`, `untitled_kind`, `merge_suggestion` and `audio_stale`, and `GET /untitled` plus `quality/report → untitled_articles` list everything. Changes happen only through the undoable `POST /articles/{id}/merge-into-previous` and `POST /articles/{id}/accept-title` (never over a real title), `POST /titles/suggest` (suggestion cache only), and the CLI's `--apply` (backup first). A merge keeps reading order, marks the article `audio_stale` (a fresh render clears it) and deletes no audio. See architecture/magazine-ingestion.md, "Untitled entries".
- **Distinct, deterministic face profile per character** (`core/face_profile.py`). The cast of one book looked like one family because most sheets name no facial feature and the image model then draws its default face. Each character now gets a compact `face:` clause (face shape, jaw, nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a mark) in the Subject of every character prompt: traits the sheet states (German or English) are kept, the rest comes from species/age/gender/culture-filtered option pools, chosen so that the characters of a book are spread apart (greedy max-distance in creation order; adding characters never changes an existing profile; relatives named in `relationships` share a few traits). Owner override: a `visual_face` text on the sheet. On the real books 02/03 the mean pairwise face-descriptor distance goes from 0.09/0.04 to 0.91 (44/68 characters had no face trait at all). See `architecture/character-visual-identity.md`.
- **Dry run for faces:** `POST /api/character-face-preview {book}` lists every character's face profile (what came from the sheet, what was chosen) with a similarity matrix and before/after score; `POST /api/character-prompt-preview` also returns `face_clause`. Nothing is generated or saved.
diff --git a/Manual.md b/Manual.md
index c4a6b34..5915b52 100644
--- a/Manual.md
+++ b/Manual.md
@@ -353,6 +353,12 @@ turned into a podcast discussion between two or three voices you pick.
switch which one the drawing and editing tools apply to. Confirm the
boundaries, correct the title, then **Approve**.
**Approve** on one page of a multi-page article keeps the blocks of all its pages: only boxes you untick or add change what the article contains.
+
+ **Untitled entries ("Page 33") in review.** An entry without a real title shows its page label (for example "Seite 33") and a suggestion box:
+ - *Merge into previous article* appears when the page looks like the continuation of the article before it. Open **Why do we think so?** to read the reasons in plain words and the confidence. Press the button to merge. Use **Undo** in the bar above the page to take it back. If the suggestion changed in the meantime nothing is merged and the suggestion is refreshed.
+ - *Use suggested title* writes the proposed title into the entry (never over a title you set). Undo is offered.
+ - After a merge the article's audio is marked *Audio is out of date: re-render*; render it again from the contents list. No audio file is deleted.
+ Page labels and these badges also appear in the contents list, the playlist and the driving-mode index. Mobile: buttons are full width and 44 px high; keyboard: Tab to the buttons and the details summary, Enter or Space to use them. Privacy: nothing leaves the device beyond the local server.
Optionally, click **"Describe"** under a figure, chart, table or code block
to have the configured vision engine generate a one- or two-sentence
description for listeners who cannot see the image — e.g. what a chart
diff --git a/static/js/magazine.js b/static/js/magazine.js
index ba5e0ce..829afa8 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -895,6 +895,23 @@ async function magReviewPage(pageNumber) {
magReviewGoMagazinePage(pageNumber);
}
+// An entry without a real title ("Page 33") shows the server's derived title; the label says where it is.
+function magDisplayTitle(a) { return (a && (a.display_title || a.title)) || 'Untitled'; }
+function magPageLabelHtml(a, cls) {
+ return a && a.page_label ? '' + esc(a.page_label) + '' : '';
+}
+const MAG_KIND_LABELS = { advert: 'Advert', photo: 'Photo page', content: 'Contents', imprint: 'Imprint', continuation: 'Continuation' };
+function magKindBadgeHtml(a) {
+ const k = MAG_KIND_LABELS[a && a.untitled_kind];
+ return k ? '' + esc(k) + '' : '';
+}
+function magStaleHtml(a) {
+ return a && a.audio_stale
+ ? '' +
+ ' Audio is out of date: re-render'
+ : '';
+}
+
function renderContents() {
const host = $('mag-contents');
if (!host) return;
@@ -969,7 +986,7 @@ function renderContents() {
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
'onclick="event.stopPropagation()" title="Click to edit the title">' +
- esc(a.title || 'Untitled') + '';
+ esc(magDisplayTitle(a)) + '' + magPageLabelHtml(a, 'mag-page-label');
let html = '';
bySection.forEach((arts, section) => {
@@ -988,6 +1005,7 @@ function renderContents() {
'