feat(magazine): page overview shows status, article count, page flow and what to do next
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
85c5b8427a
commit
7e9be98709
@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- **Page overview says what to do.** Every page card now states its status in words, how many articles are on the page, where each article continues from / on to ("continues from p.3", "continues on p.6"), and a **To do** list when the system could not settle the page (approve it; fix the quality findings with the tool that fixes each; link orphan text to an article; generate audio; approve the other articles on a shared page). A summary line counts the pages that need you and *Show only pages that need me* hides the rest. The legend is larger (14 px, 18 px swatches). A page coloured "audio" that still has an unapproved article is now labelled "Partly done" with the reason.
|
||||||
- **Hands-free listening.** The article player now registers with the browser's Media Session: title, magazine and first-page artwork on the lock screen / headset / car display, with play, pause, previous/next article, ±15 s and seek, plus a position bar. While driving mode is open and audio is playing the screen is kept awake (Screen Wake Lock), and released on pause or exit; a refused lock never affects playback.
|
- **Hands-free listening.** The article player now registers with the browser's Media Session: title, magazine and first-page artwork on the lock screen / headset / car display, with play, pause, previous/next article, ±15 s and seek, plus a position bar. While driving mode is open and audio is playing the screen is kept awake (Screen Wake Lock), and released on pause or exit; a refused lock never affects playback.
|
||||||
- **EPUB 3 with Media Overlays.** *EPUB with audio* in the contents toolbar (and `GET /api/magazine/{id}/epub`) packages the approved articles that have audio into a book whose text highlights as the audio plays. MP3 export now records each sentence's real start and end (`audio/<id>.timings.json`, no text), so new audio highlights exactly; older audio is placed by estimate and the book's description says so. Validated with W3C EPUBCheck 5.2.1 (0 errors, 0 warnings) on a real 12-minute two-article issue.
|
- **EPUB 3 with Media Overlays.** *EPUB with audio* in the contents toolbar (and `GET /api/magazine/{id}/epub`) packages the approved articles that have audio into a book whose text highlights as the audio plays. MP3 export now records each sentence's real start and end (`audio/<id>.timings.json`, no text), so new audio highlights exactly; older audio is placed by estimate and the book's description says so. Validated with W3C EPUBCheck 5.2.1 (0 errors, 0 warnings) on a real 12-minute two-article issue.
|
||||||
- **Magazine reading view shows sidebars.** A sidebar was kept out of narration and therefore never shown at all. The article text endpoint now also returns `asides` and the reflowed view displays each one where it sits, labelled "Sidebar · not read aloud · p. N", as plain text that is neither a sentence nor highlighted. Only for pages the article alone occupies (on a shared page there is no telling which article a sidebar belongs to); not for advertisement pages; a sidebar you ticked into the narration stays narrated.
|
- **Magazine reading view shows sidebars.** A sidebar was kept out of narration and therefore never shown at all. The article text endpoint now also returns `asides` and the reflowed view displays each one where it sits, labelled "Sidebar · not read aloud · p. N", as plain text that is neither a sentence nor highlighted. Only for pages the article alone occupies (on a shared page there is no telling which article a sidebar belongs to); not for advertisement pages; a sidebar you ticked into the narration stays narrated.
|
||||||
|
|||||||
27
Manual.md
27
Manual.md
@ -340,6 +340,33 @@ Open an article and expand **Pronunciation & spelling** under the player.
|
|||||||
**Mobile / accessibility:** the lists are ordinary buttons with labels ("Replace X with Y"); marks are underlined, not colour-only.
|
**Mobile / accessibility:** the lists are ordinary buttons with labels ("Replace X with Y"); marks are underlined, not colour-only.
|
||||||
**Privacy:** the agent sends only the flagged word and its surrounding sentence to the LLM you configured in Settings; without an LLM nothing leaves the machine.
|
**Privacy:** the agent sends only the flagged word and its surrounding sentence to the LLM you configured in Settings; without an LLM nothing leaves the machine.
|
||||||
|
|
||||||
|
### The page overview: what every page needs from you
|
||||||
|
|
||||||
|
**Purpose.** The page grid (the *Thumbnails* view of a magazine's contents) shows every page of the issue at once and tells you,
|
||||||
|
in words, where you still have to act.
|
||||||
|
|
||||||
|
**Steps.** Read the line above the grid: "*N of M pages need you*". Tick **Show only pages that need me** to hide everything the
|
||||||
|
system has settled. Each card shows, top to bottom: the page, **how many articles are on it**, each article's title with
|
||||||
|
**"continues from p.X" / "continues on p.Y"** where it flows to another page, a **status** (for example *Text found, not checked yet*,
|
||||||
|
*Problems found*, *Approved*, *Done: audio is ready*, *Advertisement: not read*), and either a blue **To do** box or "Nothing to do
|
||||||
|
here". Click the page image to open it in Review.
|
||||||
|
|
||||||
|
**What the to-do lines mean.** *Open the page … and approve it*: detection found text and nobody has confirmed it. *Problems found*
|
||||||
|
lists what the quality check saw and which tool fixes it (*Find missed text*, *Clean up boxes*, *Spelling*, merge or shrink
|
||||||
|
overlapping boxes, keep a page in one article only). *Text was found here but it belongs to no article*: link the page to the
|
||||||
|
article it continues, or start a new one. *Generate the audio*: approved, but no MP3 yet. *N of the M articles here still wait for
|
||||||
|
approval*: a page shared by several articles is coloured by its most advanced one, so check the others. A small grey note tells you
|
||||||
|
when a page you approved had problems that the quality check found earlier.
|
||||||
|
|
||||||
|
**Expected result.** When the summary says nothing is left, every page is approved, settled as an advertisement or empty, or has audio.
|
||||||
|
|
||||||
|
**Errors / limits.** The quality-check lines appear only after a quality run (*Auto-fix all pages* or *Quality check*); before that,
|
||||||
|
pages show the status only. A page with no text and no article is reported as having nothing to do, which is right for a full-page
|
||||||
|
picture but worth a glance if you expected text.
|
||||||
|
|
||||||
|
**Mobile / accessibility.** Status and to-do are plain text beside the colour, so nothing depends on seeing colours; the legend is 14 px
|
||||||
|
with large swatches. On a phone use fewer columns (the *Columns* slider) for more readable cards.
|
||||||
|
|
||||||
### EPUB with synchronised audio
|
### EPUB with synchronised audio
|
||||||
|
|
||||||
**Purpose.** Take an issue to an e-reader or reading app that highlights the sentence being spoken (EPUB 3 *Media Overlays*).
|
**Purpose.** Take an issue to an e-reader or reading app that highlights the sentence being spoken (EPUB 3 *Media Overlays*).
|
||||||
|
|||||||
@ -1064,6 +1064,89 @@ function magQualityBadge(q, page) {
|
|||||||
bits.length + '</button>';
|
bits.length + '</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each page card tells the reader, in words (colour alone is not enough): what state the page is in,
|
||||||
|
// which articles sit on it and how they flow to neighbouring pages, and what -- if anything -- the reader still has to
|
||||||
|
// do because the system could not settle it alone. Pure: the card markup only renders what this returns.
|
||||||
|
const MAG_PAGE_LABELS = {
|
||||||
|
blank: ['Not looked at yet', 'mdi-help-circle-outline'],
|
||||||
|
detected: ['Text found, not checked yet', 'mdi-text-search'],
|
||||||
|
checked: ['Checked: no problems found', 'mdi-check-circle-outline'],
|
||||||
|
flagged: ['Problems found', 'mdi-alert-circle-outline'],
|
||||||
|
approved: ['Approved', 'mdi-check-decagram-outline'],
|
||||||
|
audio: ['Done: audio is ready', 'mdi-volume-high'],
|
||||||
|
ad: ['Advertisement: not read', 'mdi-bullhorn-outline'],
|
||||||
|
empty: ['Reviewed: nothing to read', 'mdi-check'],
|
||||||
|
none: ['No article on this page', 'mdi-file-hidden'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where an article comes from and goes to around `page`, by its own page list.
|
||||||
|
function magArticleFlow(article, page) {
|
||||||
|
const pages = (article?.pages || []).slice().sort((a, b) => a - b);
|
||||||
|
const i = pages.indexOf(page);
|
||||||
|
if (i < 0) return { from: null, to: null };
|
||||||
|
return { from: i > 0 ? pages[i - 1] : null, to: i < pages.length - 1 ? pages[i + 1] : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function magQualityActions(q) {
|
||||||
|
const n = (list) => (list || []).length;
|
||||||
|
const plural = (k, one, many) => k + ' ' + (k === 1 ? one : many);
|
||||||
|
const out = [];
|
||||||
|
if (n(q.uncovered)) out.push(plural(n(q.uncovered), 'text area lies', 'text areas lie') + ' outside every box: use "Find missed text"');
|
||||||
|
if (n(q.overlaps)) out.push(plural(n(q.overlaps), 'pair of boxes overlaps', 'pairs of boxes overlap') + ': merge, shrink or delete one');
|
||||||
|
if (n(q.clipped)) out.push(plural(n(q.clipped), 'box edge cuts', 'box edges cut') + ' into text: use "Clean up boxes"');
|
||||||
|
if (n(q.suspect_blocks)) out.push(plural(n(q.suspect_blocks), 'region has', 'regions have') + ' doubtful words: check them with "Spelling"');
|
||||||
|
if (n(q.duplicates)) out.push('several articles claim this page: keep it in one of them');
|
||||||
|
if (q.error) out.push('the check itself failed: run it again');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function magPageGuide({ page, articles, quality, stateName, hasText }) {
|
||||||
|
const arts = articles || [];
|
||||||
|
const name = !arts.length && stateName !== 'ad' ? 'none' : stateName;
|
||||||
|
const [label, icon] = MAG_PAGE_LABELS[name] || MAG_PAGE_LABELS.blank;
|
||||||
|
const todo = [];
|
||||||
|
let note = '';
|
||||||
|
if (!arts.length) {
|
||||||
|
if (stateName !== 'ad' && hasText) todo.push('Text was found here but it belongs to no article: open the page and link it to the article it continues, or start a new one');
|
||||||
|
} else if (stateName === 'flagged') {
|
||||||
|
const actions = magQualityActions(quality || {});
|
||||||
|
actions.slice(0, 3).forEach((t) => todo.push(t.charAt(0).toUpperCase() + t.slice(1)));
|
||||||
|
if (actions.length > 3) todo.push('and ' + (actions.length - 3) + ' more: open the page for the full list');
|
||||||
|
if (!actions.length) todo.push('Open the page and check what the quality run found');
|
||||||
|
todo.push('Then approve the page');
|
||||||
|
} else if (stateName === 'detected' || stateName === 'checked') {
|
||||||
|
todo.push(stateName === 'checked'
|
||||||
|
? 'Nothing wrong was found, but nobody has confirmed it: open the page and approve it'
|
||||||
|
: 'Open the page, check the boxes and approve it');
|
||||||
|
} else if (stateName === 'approved' && arts.some((a) => a.status === 'approved' && !a.has_audio)) {
|
||||||
|
todo.push('Generate the audio (toolbar: "Generate all audio")');
|
||||||
|
}
|
||||||
|
// A page's colour follows its most advanced article ("audio" beats "approved" beats "detected"), so a page can
|
||||||
|
// look finished while another article on it still waits. Say so whatever the colour is.
|
||||||
|
const waiting = arts.filter((a) => a.status === 'detected');
|
||||||
|
if (arts.length > 1 && waiting.length && stateName !== 'flagged') {
|
||||||
|
todo.push(waiting.length + ' of the ' + arts.length + ' articles here still wait for approval: open the page and approve ' + (waiting.length === 1 ? 'it' : 'them'));
|
||||||
|
}
|
||||||
|
if (arts.some((a) => a.status === 'approved') && quality && quality.needs_review && stateName !== 'flagged') {
|
||||||
|
const actions = magQualityActions(quality);
|
||||||
|
if (actions.length) note = 'You approved this page, but the quality check had found: ' + actions.slice(0, 2).join('; ');
|
||||||
|
}
|
||||||
|
// Never call a page "Done" while it still asks something of the reader.
|
||||||
|
const finalLabel = todo.length && name === 'audio' ? 'Partly done: some audio is ready' : label;
|
||||||
|
return { label: finalLabel, icon, name, todo, needs: todo.length > 0, note };
|
||||||
|
}
|
||||||
|
|
||||||
|
function magPageHasText(page) {
|
||||||
|
const layout = state.layout;
|
||||||
|
if (!layout) return false;
|
||||||
|
return (layout.blocks || []).some((b) => b.page === page && MAG_NARRATABLE_TYPES.includes(b.type) && String(b.text || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function magTogglePagesTodo(on) {
|
||||||
|
state.pagesOnlyTodo = !!on;
|
||||||
|
renderContents();
|
||||||
|
}
|
||||||
|
|
||||||
function renderPagesView(host) {
|
function renderPagesView(host) {
|
||||||
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
|
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
|
||||||
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
|
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
|
||||||
@ -1080,33 +1163,62 @@ function renderPagesView(host) {
|
|||||||
['checked', 'quality check clean'], ['flagged', 'quality check found problems'],
|
['checked', 'quality check clean'], ['flagged', 'quality check found problems'],
|
||||||
['approved', 'reviewed'], ['ad', 'advertisement (detected, not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'],
|
['approved', 'reviewed'], ['ad', 'advertisement (detected, not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'],
|
||||||
];
|
];
|
||||||
let html = '<p class="mag-pages-legend">' +
|
// The guide for every page first, so the summary can say how many pages still need the reader.
|
||||||
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
|
const guides = new Map();
|
||||||
'</p>';
|
|
||||||
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
|
|
||||||
for (let p = 1; p <= pageCount; p++) {
|
for (let p = 1; p <= pageCount; p++) {
|
||||||
const arts = byPage.get(p) || [];
|
const arts = byPage.get(p) || [];
|
||||||
|
const st = magPageState(arts, quality.get(p), p);
|
||||||
|
guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) });
|
||||||
|
}
|
||||||
|
const needing = [...guides.values()].filter((g) => g.guide.needs).length;
|
||||||
|
let html = '<div class="mag-pages-help">' +
|
||||||
|
'<p class="mag-pages-summary" role="status"><strong>' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + '</strong> ' +
|
||||||
|
'Each card says what state the page is in, how many articles are on it, and what to do next.</p>' +
|
||||||
|
'<label class="mag-pages-filter"><input type="checkbox" ' + (state.pagesOnlyTodo ? 'checked' : '') + ' onchange="magTogglePagesTodo(this.checked)"> Show only pages that need me</label>' +
|
||||||
|
'<p class="mag-pages-legend" aria-label="Card colours">' +
|
||||||
|
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
|
||||||
|
'</p></div>';
|
||||||
|
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
|
||||||
|
for (let p = 1; p <= pageCount; p++) {
|
||||||
|
const { st, arts, guide } = guides.get(p);
|
||||||
|
if (state.pagesOnlyTodo && !guide.needs) continue;
|
||||||
// The thumbnail is the big, easy target: it opens the page in review (in the context of the
|
// The thumbnail is the big, easy target: it opens the page in review (in the context of the
|
||||||
// first article on it). A separate Review button per article only appears when the page
|
// first article on it). A separate Review button per article only appears when the page
|
||||||
// really holds several, to say which one is meant.
|
// really holds several, to say which one is meant.
|
||||||
const reviewJs = arts.length ? 'magReviewAtPage(\'' + arts[0].id + '\', ' + p + ')' : 'magReviewPage(' + p + ')';
|
const reviewJs = arts.length ? 'magReviewAtPage(\'' + arts[0].id + '\', ' + p + ')' : 'magReviewPage(' + p + ')';
|
||||||
html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + magPageState(arts, quality.get(p), p) + '">' +
|
const flowChips = (a) => {
|
||||||
|
const f = magArticleFlow(a, p);
|
||||||
|
return (f.from ? '<span class="mag-flow" title="This article started on page ' + f.from + '"><span class="mdi mdi-arrow-left-bottom" aria-hidden="true"></span> continues from p.' + f.from + '</span>' : '') +
|
||||||
|
(f.to ? '<span class="mag-flow" title="This article goes on on page ' + f.to + '"><span class="mdi mdi-arrow-right-bottom" aria-hidden="true"></span> continues on p.' + f.to + '</span>' : '');
|
||||||
|
};
|
||||||
|
html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + st + (guide.needs ? ' needs-you' : '') + '">' +
|
||||||
'<button type="button" class="mag-card-thumb-btn" title="Review page ' + p + '" aria-label="Review page ' + p + '" onclick="' + reviewJs + '">' +
|
'<button type="button" class="mag-card-thumb-btn" title="Review page ' + p + '" aria-label="Review page ' + p + '" onclick="' + reviewJs + '">' +
|
||||||
'<img class="mag-card-thumb" loading="lazy" alt="Page ' + p + '" src="/api/magazine/' + esc(state.doc.id) + '/pages/' + p + '"></button>' +
|
'<img class="mag-card-thumb" loading="lazy" alt="Page ' + p + '" src="/api/magazine/' + esc(state.doc.id) + '/pages/' + p + '"></button>' +
|
||||||
'<span class="mag-card-title">Page ' + p + '</span>' + magQualityBadge(quality.get(p), p) +
|
'<span class="mag-card-title">Page ' + p + '</span>' +
|
||||||
|
'<span class="mag-card-count">' + (arts.length ? arts.length + (arts.length === 1 ? ' article' : ' articles') : 'no article') + '</span>' +
|
||||||
|
magQualityBadge(quality.get(p), p) +
|
||||||
(arts.length
|
(arts.length
|
||||||
? '<div class="mag-page-articles">' + arts.map((a) =>
|
? '<div class="mag-page-articles">' + arts.map((a) =>
|
||||||
'<div class="mag-page-article">' +
|
'<div class="mag-page-article">' +
|
||||||
'<span class="mag-page-article-title">' + esc(a.title || 'Untitled') + '</span>' +
|
'<span class="mag-page-article-title">' + esc(a.title || 'Untitled') + '</span>' +
|
||||||
|
(flowChips(a) ? '<span class="mag-flows">' + flowChips(a) + '</span>' : '') +
|
||||||
'<span class="mag-page-article-actions">' +
|
'<span class="mag-page-article-actions">' +
|
||||||
(arts.length > 1 ? '<button type="button" class="btn-sm" aria-label="Review ' + esc(a.title || 'article') + '" onclick="magReviewAtPage(\'' + a.id + '\', ' + p + ')">Review</button>' : '') +
|
(arts.length > 1 ? '<button type="button" class="btn-sm" aria-label="Review ' + esc(a.title || 'article') + '" onclick="magReviewAtPage(\'' + a.id + '\', ' + p + ')">Review</button>' : '') +
|
||||||
(a.has_audio ? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')" ' +
|
(a.has_audio ? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')" ' +
|
||||||
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi mdi-play" aria-hidden="true"></span> Play</button>' : '') +
|
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi mdi-play" aria-hidden="true"></span> Play</button>' : '') +
|
||||||
'</span>' +
|
'</span>' +
|
||||||
'</div>').join('') + '</div>'
|
'</div>').join('') + '</div>'
|
||||||
: '<span class="mag-pill">no article</span>') +
|
: '') +
|
||||||
|
'<div class="mag-page-guide">' +
|
||||||
|
'<span class="mag-page-status"><span class="mdi ' + guide.icon + '" aria-hidden="true"></span> ' + esc(guide.label) + '</span>' +
|
||||||
|
(guide.needs
|
||||||
|
? '<div class="mag-page-todo"><strong>To do:</strong><ul>' + guide.todo.map((t) => '<li>' + esc(t) + '</li>').join('') + '</ul></div>'
|
||||||
|
: '<span class="mag-page-done"><span class="mdi mdi-check" aria-hidden="true"></span> Nothing to do here</span>') +
|
||||||
|
(guide.note ? '<span class="mag-page-note">' + esc(guide.note) + '</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
}
|
}
|
||||||
|
if (state.pagesOnlyTodo && !needing) html += '<p class="muted">No page needs you.</p>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
}
|
}
|
||||||
@ -5513,6 +5625,9 @@ root.magReocrMagazine = magReocrMagazine;
|
|||||||
root.magApprove = magApprove;
|
root.magApprove = magApprove;
|
||||||
root.magSetStatus = magSetStatus;
|
root.magSetStatus = magSetStatus;
|
||||||
root.magRead = magRead;
|
root.magRead = magRead;
|
||||||
|
root.magPageGuide = magPageGuide;
|
||||||
|
root.magArticleFlow = magArticleFlow;
|
||||||
|
root.magTogglePagesTodo = magTogglePagesTodo;
|
||||||
root.magFindSearch = magFindSearch;
|
root.magFindSearch = magFindSearch;
|
||||||
root.magFindImport = magFindImport;
|
root.magFindImport = magFindImport;
|
||||||
root.magTocPlay = magTocPlay;
|
root.magTocPlay = magTocPlay;
|
||||||
|
|||||||
@ -5598,3 +5598,22 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
|
|||||||
.mag-order-bar.is-floating { animation: mag-order-in .18s ease-out; }
|
.mag-order-bar.is-floating { animation: mag-order-in .18s ease-out; }
|
||||||
@keyframes mag-order-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
@keyframes mag-order-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page grid: bigger legend, and a status / articles / next-step block on every card */
|
||||||
|
.mag-pages-help { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
||||||
|
.mag-pages-summary { margin: 0 0 8px; font-size: 15px; }
|
||||||
|
.mag-pages-filter { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; margin-bottom: 10px; cursor: pointer; }
|
||||||
|
.mag-pages-filter input { width: 18px; height: 18px; accent-color: var(--accent); }
|
||||||
|
.mag-pages-help .mag-pages-legend { font-size: 14px; gap: 8px 20px; margin: 0; color: var(--text); }
|
||||||
|
.mag-pages-help .mag-pages-legend-swatch { width: 18px; height: 18px; border-radius: 4px; }
|
||||||
|
.mag-card-count { display: block; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
|
.mag-flows { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.mag-flow { font-size: 12px; color: var(--subtext); }
|
||||||
|
.mag-page-guide { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 13px; line-height: 1.4; }
|
||||||
|
.mag-page-status { font-weight: 700; display: inline-flex; gap: 6px; align-items: flex-start; }
|
||||||
|
.mag-page-todo { padding: 6px 8px; border-radius: 6px; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-left: 4px solid var(--accent); }
|
||||||
|
.mag-page-todo ul { margin: 4px 0 0; padding-left: 18px; }
|
||||||
|
.mag-page-todo li { margin: 2px 0; }
|
||||||
|
.mag-page-done { color: var(--subtext); }
|
||||||
|
.mag-page-note { color: var(--subtext); font-size: 12px; }
|
||||||
|
.mag-card.needs-you { box-shadow: 0 0 0 2px var(--accent); }
|
||||||
|
|||||||
@ -1695,3 +1695,114 @@ test.describe('Hands-free listening', () => {
|
|||||||
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Page cards say what to do', () => {
|
||||||
|
const guide = (page, args) => page.evaluate((a) => window.magPageGuide(a), args);
|
||||||
|
|
||||||
|
async function openGrid(page, articles, quality) {
|
||||||
|
await mockBackend(page, { articleStatus: 'approved' });
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
|
||||||
|
json: { articles, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||||||
|
if (quality) await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { pages: quality, summary: {} } }));
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||||||
|
await navTo(page, 's-magazine');
|
||||||
|
await page.locator('#mag-library-list .mag-card').first().click();
|
||||||
|
await page.locator('#mag-contents-view-toggle').click();
|
||||||
|
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const art = (id, title, pages, status = 'detected', extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
|
||||||
|
|
||||||
|
test('the guide names the state and the next step for every kind of page', async ({ page }) => {
|
||||||
|
await mockBackend(page);
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||||||
|
await navTo(page, 's-magazine');
|
||||||
|
const a = (status, has_audio = false) => ({ id: 'x', title: 'T', pages: [3], status, has_audio });
|
||||||
|
const flagged = await guide(page, { page: 3, articles: [a('detected')], quality: { needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [['a', 'b'], ['c', 'd']], clipped: [], suspect_blocks: ['s'], duplicates: [] }, stateName: 'flagged', hasText: true });
|
||||||
|
expect(flagged.needs).toBe(true);
|
||||||
|
expect(flagged.label).toBe('Problems found');
|
||||||
|
expect(flagged.todo[0]).toMatch(/^1 text area lies outside every box: use "Find missed text"/);
|
||||||
|
expect(flagged.todo).toContain('2 pairs of boxes overlap: merge, shrink or delete one');
|
||||||
|
expect(flagged.todo.at(-1)).toBe('Then approve the page');
|
||||||
|
const detected = await guide(page, { page: 3, articles: [a('detected')], quality: null, stateName: 'detected', hasText: true });
|
||||||
|
expect(detected.todo[0]).toMatch(/approve it/);
|
||||||
|
const done = await guide(page, { page: 3, articles: [a('approved', true)], quality: null, stateName: 'audio', hasText: true });
|
||||||
|
expect([done.needs, done.label]).toEqual([false, 'Done: audio is ready']);
|
||||||
|
const noAudio = await guide(page, { page: 3, articles: [a('approved')], quality: null, stateName: 'approved', hasText: true });
|
||||||
|
expect(noAudio.todo[0]).toMatch(/Generate the audio/);
|
||||||
|
const ad = await guide(page, { page: 3, articles: [], quality: null, stateName: 'ad', hasText: false });
|
||||||
|
expect([ad.needs, ad.label]).toEqual([false, 'Advertisement: not read']);
|
||||||
|
const orphan = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: true });
|
||||||
|
expect(orphan.needs).toBe(true);
|
||||||
|
expect(orphan.todo[0]).toMatch(/belongs to no article/);
|
||||||
|
const empty = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: false });
|
||||||
|
expect([empty.needs, empty.label]).toEqual([false, 'No article on this page']);
|
||||||
|
const shared = await guide(page, { page: 3, articles: [a('detected'), a('approved')], quality: null, stateName: 'detected', hasText: true });
|
||||||
|
expect(shared.todo.join(' ')).toMatch(/1 of the 2 articles here still wait/);
|
||||||
|
const hidden = await guide(page, { page: 3, articles: [a('detected'), a('approved', true)], quality: null, stateName: 'audio', hasText: true });
|
||||||
|
expect([hidden.label, hidden.needs]).toEqual(['Partly done: some audio is ready', true]); // colour says audio, one article still waits
|
||||||
|
expect(hidden.todo.join(' ')).toMatch(/1 of the 2 articles here still wait for approval/);
|
||||||
|
const noted = await guide(page, { page: 3, articles: [a('approved', true)], quality: { needs_review: true, uncovered: [], overlaps: [['a', 'b']], clipped: [], suspect_blocks: [], duplicates: [] }, stateName: 'audio', hasText: true });
|
||||||
|
expect(noted.needs).toBe(false);
|
||||||
|
expect(noted.note).toMatch(/You approved this page, but the quality check had found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('how an article flows between pages is computed from its page list', async ({ page }) => {
|
||||||
|
await mockBackend(page);
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||||||
|
await navTo(page, 's-magazine');
|
||||||
|
const r = await page.evaluate(() => ({
|
||||||
|
first: window.magArticleFlow({ pages: [10, 11, 25] }, 10), mid: window.magArticleFlow({ pages: [10, 11, 25] }, 11),
|
||||||
|
last: window.magArticleFlow({ pages: [25, 10, 11] }, 25), single: window.magArticleFlow({ pages: [4] }, 4), absent: window.magArticleFlow({ pages: [4] }, 9) }));
|
||||||
|
expect(r.first).toEqual({ from: null, to: 11 });
|
||||||
|
expect(r.mid).toEqual({ from: 10, to: 25 });
|
||||||
|
expect(r.last).toEqual({ from: 11, to: null });
|
||||||
|
expect(r.single).toEqual({ from: null, to: null });
|
||||||
|
expect(r.absent).toEqual({ from: null, to: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every card shows its status, article count, page flow and to-do in words', async ({ page }) => {
|
||||||
|
await openGrid(page, [
|
||||||
|
art('a1', 'Long feature', [3, 4], 'detected'),
|
||||||
|
art('a2', 'Short piece', [4], 'approved', { has_audio: true }),
|
||||||
|
], [{ page: 3, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }]);
|
||||||
|
const p3 = page.locator('.mag-card-wrap[data-page="3"]');
|
||||||
|
await expect(p3.locator('.mag-card-count')).toHaveText('1 article');
|
||||||
|
await expect(p3.locator('.mag-page-status')).toContainText('Problems found');
|
||||||
|
await expect(p3.locator('.mag-flow')).toHaveText(/continues on p\.4/);
|
||||||
|
await expect(p3.locator('.mag-page-todo')).toContainText('Find missed text');
|
||||||
|
const p4 = page.locator('.mag-card-wrap[data-page="4"]');
|
||||||
|
await expect(p4.locator('.mag-card-count')).toHaveText('2 articles');
|
||||||
|
await expect(p4.locator('.mag-flow').first()).toHaveText(/continues from p\.3/);
|
||||||
|
await expect(p4.locator('.mag-page-todo')).toContainText('1 of the 2 articles here still wait for approval');
|
||||||
|
const p6 = page.locator('.mag-card-wrap[data-page="6"]');
|
||||||
|
await expect(p6.locator('.mag-card-count')).toHaveText('no article');
|
||||||
|
await expect(p6.locator('.mag-page-status')).toContainText('No article on this page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the summary counts pages that need you and the filter hides the rest', async ({ page }) => {
|
||||||
|
await openGrid(page, [art('a1', 'Done piece', [3], 'approved', { has_audio: true }), art('a2', 'Open piece', [4], 'detected')], null);
|
||||||
|
const summary = page.locator('.mag-pages-summary');
|
||||||
|
await expect(summary).toContainText(/pages need you/);
|
||||||
|
const total = await page.locator('.mag-pages-grid .mag-card-wrap').count();
|
||||||
|
await page.getByLabel('Show only pages that need me').check();
|
||||||
|
const shown = await page.locator('.mag-pages-grid .mag-card-wrap').count();
|
||||||
|
expect(shown).toBeLessThan(total);
|
||||||
|
await expect(page.locator('.mag-card-wrap[data-page="4"]')).toBeVisible();
|
||||||
|
await expect(page.locator('.mag-card-wrap[data-page="3"]')).toHaveCount(0);
|
||||||
|
await page.getByLabel('Show only pages that need me').uncheck();
|
||||||
|
await expect(page.locator('.mag-pages-grid .mag-card-wrap')).toHaveCount(total);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the legend is large enough to read', async ({ page }) => {
|
||||||
|
await openGrid(page, [art('a1', 'X', [3])], null);
|
||||||
|
const sizes = await page.evaluate(() => ({
|
||||||
|
font: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-item')).fontSize),
|
||||||
|
swatch: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-swatch')).width) }));
|
||||||
|
expect(sizes.font).toBeGreaterThanOrEqual(14);
|
||||||
|
expect(sizes.swatch).toBeGreaterThanOrEqual(18);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user