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:
mARTin-B78 2026-09-30 10:59:08 +02:00
parent 85c5b8427a
commit 7e9be98709
5 changed files with 280 additions and 7 deletions

View File

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

View File

@ -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.
**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
**Purpose.** Take an issue to an e-reader or reading app that highlights the sentence being spoken (EPUB 3 *Media Overlays*).

View File

@ -1064,6 +1064,89 @@ function magQualityBadge(q, page) {
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) {
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
@ -1080,33 +1163,62 @@ function renderPagesView(host) {
['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'],
];
let html = '<p class="mag-pages-legend">' +
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
'</p>';
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
// The guide for every page first, so the summary can say how many pages still need the reader.
const guides = new Map();
for (let p = 1; p <= pageCount; 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
// 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) + '">' +
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 + '">' +
'<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
? '<div class="mag-page-articles">' + arts.map((a) =>
'<div class="mag-page-article">' +
'<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">' +
(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 + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi mdi-play" aria-hidden="true"></span> Play</button>' : '') +
'</span>' +
'</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>';
}
if (state.pagesOnlyTodo && !needing) html += '<p class="muted">No page needs you.</p>';
html += '</div>';
host.innerHTML = html;
}
@ -5513,6 +5625,9 @@ root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove;
root.magSetStatus = magSetStatus;
root.magRead = magRead;
root.magPageGuide = magPageGuide;
root.magArticleFlow = magArticleFlow;
root.magTogglePagesTodo = magTogglePagesTodo;
root.magFindSearch = magFindSearch;
root.magFindImport = magFindImport;
root.magTocPlay = magTocPlay;

View File

@ -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; }
@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); }

View File

@ -1695,3 +1695,114 @@ test.describe('Hands-free listening', () => {
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);
});
});