feat(magazine): page thumbnail is the Review target; one button per article only on shared pages

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 17:49:21 +02:00
parent 25eea74096
commit 9fa661fb6a
4 changed files with 56 additions and 14 deletions

View File

@ -42,6 +42,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Stacked boxes of a column now meet at the gap between two lines** (*seam mending*). Detection often cuts a column into slices that overlap or leave a line in the seam, so that line was in neither box and both reported a clipped edge. The shared boundary moves to the nearest blank gap (below the line for a paragraph that is still running, above it after a finished one, in the middle of an overlap); a line that had fallen into the seam is read on its own and attached to the box that now owns it, and the box takes the column's width. Page 32 of the measured issue: clipped edges 2 → 0 and the lost line "Entwicklungen auseinander. „KI ist bei uns in allen" is back in the text. 57 seams were mended across the issue. - **Stacked boxes of a column now meet at the gap between two lines** (*seam mending*). Detection often cuts a column into slices that overlap or leave a line in the seam, so that line was in neither box and both reported a clipped edge. The shared boundary moves to the nearest blank gap (below the line for a paragraph that is still running, above it after a finished one, in the middle of an overlap); a line that had fallen into the seam is read on its own and attached to the box that now owns it, and the box takes the column's width. Page 32 of the measured issue: clipped edges 2 → 0 and the lost line "Entwicklungen auseinander. „KI ist bei uns in allen" is back in the text. 57 seams were mended across the issue.
- **Unboxed ink in or beside a picture, chart or advert is no longer reported as missed text** (labels of a chart, text set into a photo, an advert's own wording). - **Unboxed ink in or beside a picture, chart or advert is no longer reported as missed text** (labels of a chart, text set into a photo, an advert's own wording).
### Changed
- **The page thumbnail is the Review button.** In the thumbnail grid, clicking the (big) page image opens the page in review; the separate *Review* / *Review this page* buttons are gone. A page shared by two or more articles keeps one labelled *Review* button per article, so it stays clear which is meant.
### Fixed ### Fixed
- **An approved page is no longer purple.** A page the quality check flagged stayed purple even after you approved its article; now your approval wins and the page shows its review colour (the warning badge on the card still lists what the check found). Purple means flagged *and* nobody has approved anything on the page. An old MP3 on an article nobody approved (left over from before pages were linked) no longer turns a page green either: audio only counts for approved articles. - **An approved page is no longer purple.** A page the quality check flagged stayed purple even after you approved its article; now your approval wins and the page shows its review colour (the warning badge on the card still lists what the check found). Purple means flagged *and* nobody has approved anything on the page. An old MP3 on an article nobody approved (left over from before pages were linked) no longer turns a page green either: audio only counts for approved articles.
- **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply. - **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply.

View File

@ -925,21 +925,25 @@ function renderPagesView(host) {
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">'; 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) || [];
// 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
// really holds several, to say which one is meant.
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) + '">' + html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + magPageState(arts, quality.get(p), p) + '">' +
'<img class="mag-card-thumb" loading="lazy" alt="Page ' + p + '" src="/api/magazine/' + esc(state.doc.id) + '/pages/' + p + '">' + '<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>' +
'<span class="mag-card-title">Page ' + p + '</span>' + magQualityBadge(quality.get(p), p) + '<span class="mag-card-title">Page ' + p + '</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>' +
'<span class="mag-page-article-actions">' + '<span class="mag-page-article-actions">' +
'<button type="button" class="btn-sm" 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>' + : '<span class="mag-pill">no article</span>') +
'<div class="mag-toc-actions"><button type="button" class="btn-sm" onclick="magReviewPage(' + p + ')">Review this page</button></div>') +
'</div></div>'; '</div></div>';
} }
html += '</div>'; html += '</div>';

View File

@ -5531,3 +5531,8 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-flow-chip.is-open { pointer-events: auto; cursor: pointer; background: #b45309; border: 0; font: inherit; font-size: 11px; } .mag-flow-chip.is-open { pointer-events: auto; cursor: pointer; background: #b45309; border: 0; font: inherit; font-size: 11px; }
.mag-flow-chip.is-open:hover, .mag-flow-chip.is-open:focus-visible { background: #92400e; } .mag-flow-chip.is-open:hover, .mag-flow-chip.is-open:focus-visible { background: #92400e; }
/* Page thumbnail as the Review target */
.mag-card-thumb-btn { display: block; width: 100%; padding: 0; margin: 0; border: 0; background: none; cursor: pointer; border-radius: 6px; }
.mag-card-thumb-btn:hover .mag-card-thumb, .mag-card-thumb-btn:focus-visible .mag-card-thumb { outline: 3px solid var(--accent); outline-offset: 2px; }
.mag-card-thumb-btn:focus-visible { outline: none; }

View File

@ -111,7 +111,7 @@ test.describe('Magazine reader', () => {
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click(); await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }) await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: 'Review', exact: true }).click(); .getByRole('button', { name: /^Review/ }).click();
const all = page.locator('[data-mag-check-all]').first(); const all = page.locator('[data-mag-check-all]').first();
await expect(all).toBeChecked(); await expect(all).toBeChecked();
@ -130,7 +130,7 @@ test.describe('Magazine reader', () => {
await page.locator('#mag-contents-view-toggle').click(); await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 8' }) await page.locator('.mag-card-wrap').filter({ hasText: 'Page 8' })
.getByRole('button', { name: 'Review', exact: true }).click(); .getByRole('button', { name: /^Review/ }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 8'); await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 8');
}); });
@ -149,7 +149,7 @@ test.describe('Magazine reader', () => {
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine'); await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Review', exact: true }).click(); await page.getByRole('button', { name: /^Review/ }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3'); await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click(); await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4'); await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4');
@ -171,7 +171,7 @@ test.describe('Magazine reader', () => {
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click(); await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }) await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: 'Review', exact: true }).click(); .getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: 'Fix figure boundaries (this page)', exact: true }).click(); await page.getByRole('button', { name: 'Fix figure boundaries (this page)', exact: true }).click();
await expect.poll(() => requested).toContain('/pages/3/figures/refine'); await expect.poll(() => requested).toContain('/pages/3/figures/refine');
expect(requested).not.toBe(`/api/magazine/${DOC_ID}/figures/refine`); expect(requested).not.toBe(`/api/magazine/${DOC_ID}/figures/refine`);
@ -454,7 +454,7 @@ test.describe('Magazine pronunciation and spelling checks', () => {
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click(); await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }) await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: 'Review', exact: true }).click(); .getByRole('button', { name: /^Review/ }).click();
await expect(page.locator('#mag-review .mag-block mark.mag-spell')).toHaveText('entering'); await expect(page.locator('#mag-review .mag-block mark.mag-spell')).toHaveText('entering');
await page.getByRole('button', { name: /^Spelling/ }).click(); await page.getByRole('button', { name: /^Spelling/ }).click();
@ -464,6 +464,36 @@ test.describe('Magazine pronunciation and spelling checks', () => {
}); });
}); });
test.describe('Magazine: thumbnail as the Review target', () => {
async function openGrid(page) {
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();
await page.locator('#mag-contents-view-toggle').click();
return page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' });
}
test('a page with one article has a single Review target: its thumbnail', async ({ page }) => {
await mockBackend(page);
const card = await openGrid(page);
await expect(card.getByRole('button', { name: /Review/ })).toHaveCount(1);
await card.getByRole('button', { name: 'Review page 3' }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
});
test('a page shared by two articles keeps one labelled Review button per article', async ({ page }) => {
await mockBackend(page);
const second = { ...ARTICLE_DETECTED, id: 'article-002', title: 'A Second Piece', pages: [3], blocks: [] };
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
json: { articles: [{ ...ARTICLE_DETECTED, has_audio: false }, second], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
const card = await openGrid(page);
await expect(card.getByRole('button', { name: 'Review page 3' })).toHaveCount(1);
await expect(card.getByRole('button', { name: 'Review A Second Piece' })).toHaveCount(1);
await expect(card.getByRole('button', { name: 'Review The Future of Batteries' })).toHaveCount(1);
});
});
test.describe('Magazine: page colours', () => { test.describe('Magazine: page colours', () => {
async function openGrid(page, articleStatus) { async function openGrid(page, articleStatus) {
await mockBackend(page, { articleStatus }); await mockBackend(page, { articleStatus });
@ -540,7 +570,7 @@ test.describe('Magazine: what is wrong on this page', () => {
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click(); await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }) await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: 'Review', exact: true }).click(); .getByRole('button', { name: /^Review/ }).click();
} }
test('each problem gets its own marker on the page and a plain-language row with a fix', async ({ page }) => { test('each problem gets its own marker on the page and a plain-language row with a fix', async ({ page }) => {
@ -593,7 +623,7 @@ test.describe('Magazine: text flow indicator', () => {
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click(); await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }) await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: 'Review', exact: true }).click(); .getByRole('button', { name: /^Review/ }).click();
} }
test('review draws an arrow for a continuation on the page and chips for the other pages and the ends', async ({ page }) => { test('review draws an arrow for a continuation on the page and chips for the other pages and the ends', async ({ page }) => {
@ -671,7 +701,7 @@ test('an article is linked to a page it was not connected to, from the review pa
await navTo(page, 's-magazine'); await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click(); await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: 'Review', exact: true }).click(); await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-issues-panel .mag-issues-head'); await page.waitForSelector('#mag-issues-panel .mag-issues-head');
await page.getByLabel('Link to page').fill('4'); await page.getByLabel('Link to page').fill('4');
await page.getByRole('button', { name: 'Article continues there' }).click(); await page.getByRole('button', { name: 'Article continues there' }).click();
@ -696,7 +726,7 @@ test('drawing a chart links the crop and excludes enclosed narration on approval
await page.waitForSelector('#app-skeleton', { state: 'detached' }); await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine'); await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Review', exact: true }).click(); await page.getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click(); await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await page.locator('#mag-review-page-wrap').evaluate(el => { await page.locator('#mag-review-page-wrap').evaluate(el => {
el.style.width = '400px'; el.style.width = '400px';
@ -758,7 +788,7 @@ async function openReview(page) {
await page.waitForSelector('#app-skeleton', { state: 'detached' }); await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine'); await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Review', exact: true }).click(); await page.getByRole('button', { name: /^Review/ }).click();
} }
test('a region type can be changed from the review list without toggling its checkbox', async ({ page }) => { test('a region type can be changed from the review list without toggling its checkbox', async ({ page }) => {