feat(magazine): show page labels, display titles and merge suggestions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-03 19:22:55 +02:00
parent 42bb556db9
commit 03ebd9ba35
5 changed files with 234 additions and 12 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### 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).
- **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". - **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`. - **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. - **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.

View File

@ -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 switch which one the drawing and editing tools apply to. Confirm the
boundaries, correct the title, then **Approve**. 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. **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 Optionally, click **"Describe"** under a figure, chart, table or code block
to have the configured vision engine generate a one- or two-sentence 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 description for listeners who cannot see the image — e.g. what a chart

View File

@ -895,6 +895,23 @@ async function magReviewPage(pageNumber) {
magReviewGoMagazinePage(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 ? '<span class="' + cls + '">' + esc(a.page_label) + '</span>' : '';
}
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 ? '<span class="mag-pill mag-kind-badge" title="Detected kind of this untitled entry">' + esc(k) + '</span>' : '';
}
function magStaleHtml(a) {
return a && a.audio_stale
? '<span class="mag-pill warn mag-stale-badge" title="Pages were merged into this article after its audio was made">' +
'<span class="mdi mdi-alert-outline" aria-hidden="true"></span> Audio is out of date: re-render</span>'
: '';
}
function renderContents() { function renderContents() {
const host = $('mag-contents'); const host = $('mag-contents');
if (!host) return; if (!host) return;
@ -969,7 +986,7 @@ function renderContents() {
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' + 'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' + 'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
'onclick="event.stopPropagation()" title="Click to edit the title">' + 'onclick="event.stopPropagation()" title="Click to edit the title">' +
esc(a.title || 'Untitled') + '</span>'; esc(magDisplayTitle(a)) + '</span>' + magPageLabelHtml(a, 'mag-page-label');
let html = ''; let html = '';
bySection.forEach((arts, section) => { bySection.forEach((arts, section) => {
@ -988,6 +1005,7 @@ function renderContents() {
'<div class="mag-toc-main">' + '<div class="mag-toc-main">' +
titleHtml(a, 'mag-toc-title') + titleHtml(a, 'mag-toc-title') +
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') + (a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
magKindBadgeHtml(a) + magStaleHtml(a) +
'</div>' + '</div>' +
'<div class="mag-toc-meta">' + '<div class="mag-toc-meta">' +
'<span>' + fmtPages(a.pages) + '</span>' + '<span>' + fmtPages(a.pages) + '</span>' +
@ -1375,7 +1393,7 @@ async function magContentsCommitTitle(el, id) {
const a = state.articles.find((x) => x.id === id); const a = state.articles.find((x) => x.id === id);
if (!a) return; if (!a) return;
const value = (el.textContent || '').replace(/\s+/g, ' ').trim(); const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; } if (!value || value === (a.title || '') || value === magDisplayTitle(a)) { el.textContent = magDisplayTitle(a); return; }
const previous = a.title; const previous = a.title;
a.title = value; a.title = value;
try { try {
@ -1385,7 +1403,7 @@ async function magContentsCommitTitle(el, id) {
}); });
} catch (e) { } catch (e) {
a.title = previous; a.title = previous;
el.textContent = previous || 'Untitled'; el.textContent = magDisplayTitle(a);
say('Could not save the title: ' + e.message, 'err'); say('Could not save the title: ' + e.message, 'err');
} }
} }
@ -2066,6 +2084,7 @@ function renderReview() {
'<div id="mag-spell-panel" class="mag-spell-panel" ' + (state.spellOpen ? '' : 'hidden') + '></div>' + '<div id="mag-spell-panel" class="mag-spell-panel" ' + (state.spellOpen ? '' : 'hidden') + '></div>' +
'<div id="mag-issues-panel" class="mag-issues-panel" role="region" aria-label="What is wrong on this page" aria-live="polite"></div>'; '<div id="mag-issues-panel" class="mag-issues-panel" role="region" aria-label="What is wrong on this page" aria-live="polite"></div>';
html += magReviewSuggestHtml(a);
html += '<div class="mag-review-fields">'; html += '<div class="mag-review-fields">';
html += '<div class="mag-review-field-row">' + html += '<div class="mag-review-field-row">' +
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' + '<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
@ -3956,6 +3975,81 @@ async function magRefineAllFigures() {
return magRunFigureRefine(null); return magRunFigureRefine(null);
} }
function magReviewSuggestHtml(a) {
const m = a.merge_suggestion;
const placeholderTitle = a.title_source === 'suggested' && a.suggested_title;
if (!m && !placeholderTitle && !a.audio_stale && !a.untitled_kind) return '';
let h = '<section class="mag-suggest" aria-label="Suggestions for this entry">';
h += '<p class="mag-suggest-head">' + esc(a.page_label || '') + (a.untitled_kind ? ' ' + magKindBadgeHtml(a) : '') + '</p>';
if (a.audio_stale) h += '<p class="mag-suggest-stale" role="status">' + magStaleHtml(a) + '</p>';
if (m) {
const lvl = m.level === 'high' ? 'high' : 'medium';
const pct = Math.round(Number(m.confidence || 0) * 100);
h += '<p>This looks like the continuation of <strong>' + esc(m.into_title || 'the article before it') + '</strong> (' +
lvl + ' confidence, ' + pct + '%).</p>' +
'<details class="mag-suggest-why"><summary>Why do we think so?</summary><ul>' +
(m.evidence || []).map((e) => '<li>' + esc(e) + '</li>').join('') + '</ul></details>' +
'<button type="button" class="btn-sm mag-suggest-btn" id="mag-merge-prev-btn" onclick="magMergeIntoPrevious()">' +
'<span class="mdi mdi-call-merge" aria-hidden="true"></span> Merge into previous article</button> ';
}
if (placeholderTitle) {
h += '<button type="button" class="btn-sm mag-suggest-btn" id="mag-accept-title-btn" onclick="magAcceptSuggestedTitle()">' +
'<span class="mdi mdi-check" aria-hidden="true"></span> Use suggested title</button> ' +
'<span class="muted">&ldquo;' + esc(a.suggested_title) + '&rdquo;</span>';
}
return h + '</section>';
}
async function magReselectArticle(id) {
await magRefreshArticles();
const next = (state.articles || []).find((x) => x.id === id);
if (next) { state.article = next; _magReviewChecked = null; }
return next;
}
async function magMergeIntoPrevious() {
const a = state.article;
if (!a || !a.merge_suggestion) return;
const btn = $('mag-merge-prev-btn');
if (btn) btn.disabled = true;
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/merge-into-previous', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ into: a.merge_suggestion.into }),
});
await magRefreshArticles();
const target = (state.articles || []).find((x) => x.id === (r.article && r.article.id)) || (state.articles || [])[0];
if (target) await magReview(target.id); else magShow('contents');
magOfferUndo(r.undo, 'Merged ' + (a.page_label || 'the page') + ' into ' + magDisplayTitle(target || {}));
say('Merged. Audio of the target is out of date: re-render it.', 'ok');
} catch (e) {
if (btn) btn.disabled = false;
if (/proposal changed|No merge is proposed/i.test(String(e.message))) {
await magReselectArticle(a.id);
renderReview();
return say('The proposal changed, so nothing was merged. The suggestion is refreshed.', 'err');
}
say('Could not merge: ' + e.message, 'err');
}
}
async function magAcceptSuggestedTitle() {
const a = state.article;
if (!a) return;
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/accept-title', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
await magReselectArticle(a.id);
renderReview();
magOfferUndo(r.undo, 'Title set to "' + ((r.article && r.article.title) || '') + '"');
} catch (e) {
await magReselectArticle(a.id);
renderReview();
say('Could not use the suggested title: ' + e.message, 'err');
}
}
async function magApprove() { async function magApprove() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const a = state.article; const a = state.article;
@ -5082,12 +5176,12 @@ function renderPlaylistTracks() {
const duration = a.audio_seconds || progress.duration || a.estimated_seconds; const duration = a.audio_seconds || progress.duration || a.estimated_seconds;
return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' + return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' +
'<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' + '<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi ' + 'aria-label="Play ' + esc(magDisplayTitle(a)) + '"><span class="mdi ' +
(active ? 'mdi-volume-high' : completed ? 'mdi-replay' : 'mdi-play') + '" aria-hidden="true"></span></button>' + (active ? 'mdi-volume-high' : completed ? 'mdi-replay' : 'mdi-play') + '" aria-hidden="true"></span></button>' +
'<span class="mag-playlist-track-page">' + esc(fmtPages(a.pages)) + '</span>' + '<span class="mag-playlist-track-page">' + esc(fmtPages(a.pages)) + '</span>' +
'<span class="mag-playlist-track-main">' + '<span class="mag-playlist-track-main">' +
'<span class="mag-playlist-track-title">' + esc(a.title || 'Untitled') + '</span>' + '<span class="mag-playlist-track-title">' + esc(magDisplayTitle(a)) + '</span>' +
(a.author ? '<span class="mag-playlist-track-sub muted">by ' + esc(a.author) + '</span>' : '') + ((a.page_label || a.author) ? '<span class="mag-playlist-track-sub muted">' + esc([a.page_label, a.author ? 'by ' + a.author : ''].filter(Boolean).join(' · ')) + '</span>' : '') +
'</span>' + '</span>' +
'<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' + '<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' +
'<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' + '<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' +
@ -5430,7 +5524,7 @@ function magPlaylistPlay(articleId, options) {
} }
const titleEl = $('mag-playlist-title'); const titleEl = $('mag-playlist-title');
const subEl = $('mag-playlist-sub'); const subEl = $('mag-playlist-sub');
if (titleEl) titleEl.textContent = a.title || 'Untitled'; if (titleEl) titleEl.textContent = magDisplayTitle(a);
if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages); if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages);
const cover = $('mag-playlist-cover'); const cover = $('mag-playlist-cover');
if (cover) { if (cover) {
@ -5870,9 +5964,9 @@ function renderDriving() {
const a = state.article; const a = state.article;
if (!a) return; if (!a) return;
const titleEl = $('mag-driving-title'); const titleEl = $('mag-driving-title');
if (titleEl) titleEl.textContent = a.title || 'Untitled'; if (titleEl) titleEl.textContent = magDisplayTitle(a);
const byEl = $('mag-driving-by'); const byEl = $('mag-driving-by');
if (byEl) byEl.textContent = a.author ? 'By ' + a.author : ''; if (byEl) byEl.textContent = [a.page_label, a.author ? 'By ' + a.author : ''].filter(Boolean).join(' · ');
syncDrivingPlayButton(); syncDrivingPlayButton();
const qEl = $('mag-driving-queue'); const qEl = $('mag-driving-queue');
if (qEl) { if (qEl) {
@ -6464,8 +6558,8 @@ function magDrivingIndexRender() {
'<button type="button" class="ix-play" data-id="' + id + '"' + dis + ' aria-label="' + esc(magIndexPlayLabel(ls, a)) + '">' + '<button type="button" class="ix-play" data-id="' + id + '"' + dis + ' aria-label="' + esc(magIndexPlayLabel(ls, a)) + '">' +
'<span class="mdi ' + magIndexPlayIcon(ls) + '" aria-hidden="true"></span></button>' + '<span class="mdi ' + magIndexPlayIcon(ls) + '" aria-hidden="true"></span></button>' +
'<button type="button" class="ix-row" data-id="' + id + '"' + dis + '>' + '<button type="button" class="ix-row" data-id="' + id + '"' + dis + '>' +
'<span class="mdi-num">' + (n + 1) + '</span><span class="ix-main"><span class="ix-title">' + esc(a.title || 'Untitled') + '</span>' + '<span class="mdi-num">' + (n + 1) + '</span><span class="ix-main"><span class="ix-title">' + esc(magDisplayTitle(a)) + '</span>' +
(a.author ? '<span class="ix-author">by ' + esc(a.author) + '</span>' : '') + '</span>' + ((a.page_label || a.author) ? '<span class="ix-author ix-page">' + esc([a.page_label, a.author ? 'by ' + a.author : ''].filter(Boolean).join(' · ')) + '</span>' : '') + '</span>' +
'<span class="ix-dur">' + esc(dur) + '</span>' + '<span class="ix-dur">' + esc(dur) + '</span>' +
'<span class="ix-status" role="img" aria-label="' + ls.label + '"><span class="mdi ' + ls.statusIcon + '" aria-hidden="true"></span></span></button></li>'; '<span class="ix-status" role="img" aria-label="' + ls.label + '"><span class="mdi ' + ls.statusIcon + '" aria-hidden="true"></span></span></button></li>';
}).join(''); }).join('');
@ -6473,7 +6567,7 @@ function magDrivingIndexRender() {
} }
function magIndexPlayIcon(ls) { return ls.completed ? 'mdi-replay' : ls.started ? 'mdi-restore' : 'mdi-play'; } function magIndexPlayIcon(ls) { return ls.completed ? 'mdi-replay' : ls.started ? 'mdi-restore' : 'mdi-play'; }
function magIndexPlayLabel(ls, a) { function magIndexPlayLabel(ls, a) {
return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + (a.title || 'article'); return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + magDisplayTitle(a);
} }
// Live update of the three states (class, icons, accessible text) without re-rendering the 100+ rows. // Live update of the three states (class, icons, accessible text) without re-rendering the 100+ rows.
function magDrivingIndexRefresh() { function magDrivingIndexRefresh() {
@ -6734,6 +6828,8 @@ root.magContentsCommitTitle = magContentsCommitTitle;
root.magGenerateAllAudio = magGenerateAllAudio; root.magGenerateAllAudio = magGenerateAllAudio;
root.magTogglePlaylist = magTogglePlaylist; root.magTogglePlaylist = magTogglePlaylist;
root.magReviewPage = magReviewPage; root.magReviewPage = magReviewPage;
root.magMergeIntoPrevious = magMergeIntoPrevious;
root.magAcceptSuggestedTitle = magAcceptSuggestedTitle;
root.magCommitDocField = magCommitDocField; root.magCommitDocField = magCommitDocField;
root.magSetPagesCols = magSetPagesCols; root.magSetPagesCols = magSetPagesCols;
root.magPlaylistPlay = magPlaylistPlay; root.magPlaylistPlay = magPlaylistPlay;

View File

@ -5972,3 +5972,20 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button:disabled { opacity: 1; color: var(--dim); } .mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button:disabled { opacity: 1; color: var(--dim); }
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button { background: var(--surface); } .mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button { background: var(--surface); }
.mag-driving[data-dtheme="dark"] .mag-driving-seek input[type="range"] { accent-color: var(--accent); } .mag-driving[data-dtheme="dark"] .mag-driving-seek input[type="range"] { accent-color: var(--accent); }
/* Untitled entries: page labels, kind/stale badges and the merge/title suggestion in review */
.mag-page-label { display: block; font-size: 12px; font-weight: 400; color: var(--subtext); }
.mag-kind-badge, .mag-stale-badge { margin-left: 8px; font-size: 12px; }
.mag-stale-badge { white-space: normal; }
.mag-driving-index-list .ix-page { font-variant-numeric: tabular-nums; }
.mag-suggest { border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius, 8px);
padding: 10px 12px; margin: 0 0 12px; background: var(--surface); color: var(--text); max-width: 100%; overflow-wrap: anywhere; }
.mag-suggest p { margin: 0 0 8px; }
.mag-suggest-head { font-size: 13px; color: var(--subtext); }
.mag-suggest-why { margin: 0 0 8px; }
.mag-suggest-why summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; }
.mag-suggest-why ul { margin: 0 0 4px; padding-left: 20px; }
.mag-suggest .mag-suggest-btn { min-height: 44px; min-width: 44px; margin: 0 8px 8px 0; }
.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); }

View File

@ -3365,6 +3365,29 @@ test.describe('Driving mode layout and views', () => {
: THREE.map((a) => ({ ...base, ...a, ...(extra.authors && a.id in extra.authors ? { author: extra.authors[a.id] } : {}) })); : THREE.map((a) => ({ ...base, ...a, ...(extra.authors && a.id in extra.authors ? { author: extra.authors[a.id] } : {}) }));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: extra.progress || {}, default_voice: '', podcast: { hosts: [] } } })); await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: extra.progress || {}, default_voice: '', podcast: { hosts: [] } } }));
} }
test('index: shows the derived title with its page label and keeps 44px hit areas and 1:1 circles', async ({ page }) => {
await setupIndex(page);
const base = { ...ARTICLE_APPROVED, status: 'approved', has_audio: true, audio_seconds: 30 };
const arts = [
{ ...base, id: 'article-001', title: 'The Future of Batteries', display_title: 'The Future of Batteries', page_label: 'Page 1', pages: [1] },
{ ...base, id: 'article-002', title: 'Page 33', display_title: 'Anzeige', title_source: 'suggested', page_label: 'Seite 33', pages: [33] },
];
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
await page.setViewportSize({ width: 320, height: 568 });
await open(page);
await page.locator('#mag-driving-indexbtn').click();
const row = page.locator('#mag-driving-index-list .ix-item').nth(1);
await expect(row.locator('.ix-title')).toHaveText('Anzeige');
await expect(row.locator('.ix-page')).toContainText('Seite 33');
const g = await page.evaluate(() => ({
plays: [...document.querySelectorAll('#mag-driving-index-list .ix-play')].map((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; }),
rows: [...document.querySelectorAll('#mag-driving-index-list .ix-row')].map((e) => e.getBoundingClientRect().height),
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
}));
for (const [w, h] of g.plays) { expect(w).toBeGreaterThanOrEqual(44); expect(Math.abs(w - h)).toBeLessThan(1); }
for (const h of g.rows) expect(h).toBeGreaterThanOrEqual(44);
expect(g.overflow).toBe(false);
});
test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => { test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => {
await setupIndex(page); await setupIndex(page);
await page.setViewportSize({ width: 375, height: 667 }); await page.setViewportSize({ width: 375, height: 667 });
@ -4119,3 +4142,82 @@ test.describe('Magazine: approving a multi-page article page by page', () => {
expect(posts).toBe(0); expect(posts).toBe(0);
}); });
}); });
test.describe('Magazine: untitled entries (labels, merge suggestion, titles, stale audio)', () => {
const mk = (id, pg, y) => ({ id, page: pg, bbox: [50, y, 200, 40], type: 'body', text: 'Text ' + id + '. More words here.', reading_order: 0, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' });
const BLOCKS = [mk('t1', 3, 60), mk('c1', 4, 60)];
const REAL = { id: 'article-001', title: 'Real Story', display_title: 'Real Story', title_source: 'title', page_label: 'Seite 3', author: '', blocks: ['t1'], images: [], status: 'approved', confidence: 0.9, section: '', pages: [3], has_audio: true, audio_seconds: 5, audio_stale: false, untitled_kind: null, merge_suggestion: null };
const STAND = { id: 'article-002', title: 'Page 4', display_title: 'Weiter im Text', title_source: 'suggested', suggested_title: 'Weiter im Text', page_label: 'Seite 4', author: '', blocks: ['c1'], images: [], status: 'detected', confidence: 0.5, section: '', pages: [4], has_audio: false, audio_stale: false, untitled_kind: 'continuation',
merge_suggestion: { into: 'article-001', into_title: 'Real Story', level: 'high', confidence: 0.91, evidence: ['starts in lower case', 'previous article ends mid-sentence'] } };
async function setup(page, { articles = [REAL, STAND], onMerge, onAccept } = {}) {
await mockBackend(page);
let list = articles.map((a) => ({ ...a }));
await page.route(`**/api/magazine/${DOC_ID}/layout`, r => r.fulfill({ json: { ...LAYOUT, page_count: 4, pages: [3, 4].map((p) => ({ page: p, width: 595, height: 842 })), blocks: BLOCKS } }));
await page.route(`**/api/magazine/${DOC_ID}/articles`, r => r.fulfill({ json: { articles: list, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*`, r => r.fulfill({ json: list[0] }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/merge-into-previous`, r => onMerge ? onMerge(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/accept-title`, r => onAccept ? onAccept(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
}
const reviewStandIn = async (page) => {
await page.locator('.mag-toc-row').filter({ hasText: 'Weiter im Text' }).getByRole('button', { name: 'Review', exact: true }).click();
};
test('the article index shows display titles with page labels and the stale hint', async ({ page }) => {
await setup(page, { articles: [{ ...REAL, audio_stale: true }, STAND] });
const rows = page.locator('.mag-toc-row');
await expect(rows.nth(1).locator('.mag-toc-title')).toHaveText('Weiter im Text');
await expect(rows.nth(1).locator('.mag-page-label')).toHaveText('Seite 4');
await expect(rows.nth(1).locator('.mag-kind-badge')).toBeVisible();
await expect(rows.nth(0).locator('.mag-stale-badge')).toContainText('Audio is out of date: re-render');
await expect(rows.nth(1).locator('.mag-stale-badge')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
// the play button of a row with audio keeps its hit area
const b = await rows.nth(0).locator('.mag-toc-play').boundingBox();
expect(b.width).toBeGreaterThanOrEqual(30);
});
test('review explains the merge suggestion and posts the proposed target', async ({ page }) => {
let posted = null;
await setup(page, { onMerge: (r) => { posted = r.request().postDataJSON(); return r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } }); } });
await reviewStandIn(page);
const box = page.locator('.mag-suggest');
await expect(box).toContainText('Real Story');
await expect(box).toContainText('high confidence');
await box.locator('summary').click();
await expect(box.locator('li')).toHaveCount(2);
const btn = page.getByRole('button', { name: 'Merge into previous article' });
expect((await btn.boundingBox()).height).toBeGreaterThanOrEqual(44);
await btn.click();
await expect.poll(() => posted).toEqual({ into: 'article-001' });
await expect(page.locator('#mag-undo-bar')).toContainText('Undo');
});
test('a 409 on merge refreshes the suggestion and merges nothing', async ({ page }) => {
await setup(page, { onMerge: (r) => r.fulfill({ status: 409, json: { detail: 'The proposal changed: it now merges into article-009' } }) });
await reviewStandIn(page);
await page.getByRole('button', { name: 'Merge into previous article' }).click();
await expect(page.locator('#toast')).toContainText('proposal changed');
await expect(page.getByRole('button', { name: 'Merge into previous article' })).toBeEnabled();
});
test('Use suggested title posts accept-title and offers undo', async ({ page }) => {
let hit = 0;
await setup(page, { onAccept: (r) => { hit++; return r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } }); } });
await reviewStandIn(page);
await page.getByRole('button', { name: 'Use suggested title' }).click();
await expect.poll(() => hit).toBe(1);
await expect(page.locator('#mag-undo-bar')).toContainText('Title set to');
});
test('review shows the out-of-date audio hint', async ({ page }) => {
await setup(page, { articles: [{ ...REAL, audio_stale: true, merge_suggestion: null }, STAND] });
await page.locator('.mag-toc-row').filter({ hasText: 'Real Story' }).getByRole('button', { name: 'Review', exact: true }).click();
await expect(page.locator('.mag-suggest-stale')).toContainText('Audio is out of date: re-render');
});
});