diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f94187..befc7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - Prompt wording that pulled every face and head toward one look was removed: "consistent facial construction" (concept style) became "individually distinct faces"; the hair locks no longer force "reaching at least to the ears or collar" / "past the shoulders" when the face clause states the hair; the dwarf body lock no longer says "a thick, full beard" (the beard now comes from the face clause, men only, so dwarf women get none); the Subject limit in the compact render prompts is 900 instead of 700 characters to make room for the clause. - `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before. ### Fixed +- **Approving a review page keeps all blocks of a multi-page article.** *Approve* without touching a box saved only the regions shown on the current page, so a multi-page article shrank to that page, and *Approve & Next* then created a `Page N` stand-in on the next page. `selectedBlockIds()` now starts from the article's saved blocks (plus visible ticks) unless ticks were changed; explicit tick selections behave as before. Test: `tests/magazine.spec.js` ("approving a multi-page article page by page"). - **Driving mode text ran out of its frame.** A very long sentence (300+ characters) in the Split view was cut off at the bottom. The text frame now takes the space between picture and controls (flex, `min-height: 0`), the current text fits automatically (1.5 rem down to 1 rem, in rem so text scaling and a 200% root font count), the picture shrinks first, and text that still does not fit scrolls inside the frame and follows the spoken word instead of being clipped. Verified at 320x568, 375x667, 414x896 and 667x375 (also with a 200% root font) in all three amounts. - **"Colors: Dark (night)" looked disabled in the light theme.** The menu item carried `data-theme="dark"`, which is also the hook of the app-wide dark palette (`[data-theme="dark"] {...}`), so the button itself got the dark palette's light text colour on the light menu. The theme buttons now use `data-theme-choice`, so no app theme selector can match them. - **A boy rendered as an adult:** `_character_age_category` read `age`, but sheets store `age_estimate`. diff --git a/Manual.md b/Manual.md index f95e0fd..c4a6b34 100644 --- a/Manual.md +++ b/Manual.md @@ -352,6 +352,7 @@ turned into a podcast discussion between two or three voices you pick. is editable at a time; click **"Edit this page"** on the other half to switch which one the drawing and editing tools apply to. Confirm the boundaries, correct the title, then **Approve**. + **Approve** on one page of a multi-page article keeps the blocks of all its pages: only boxes you untick or add change what the article contains. Optionally, click **"Describe"** under a figure, chart, table or code block to have the configured vision engine generate a one- or two-sentence description for listeners who cannot see the image — e.g. what a chart diff --git a/static/js/magazine.js b/static/js/magazine.js index 6649770..ba5e0ce 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -3615,8 +3615,11 @@ function magSelectionAddAlongside(existingId, newId) { function selectedBlockIds() { const exists = new Set((state.layout?.blocks || []).map((b) => b.id)); + // Without any tick change, the article's saved blocks stand (the DOM only shows ONE page of a + // multi-page article, so reading ticks from it would drop every other page's blocks). const ids = _magReviewChecked ? Array.from(_magReviewChecked) - : Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')).map((el) => el.dataset.block); + : Array.from(new Set((state.article?.blocks || []).concat( + Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')).map((el) => el.dataset.block)))); return state.layout ? ids.filter((id) => exists.has(id)) : ids; } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 49ed99a..bdacda3 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -4082,3 +4082,40 @@ test.describe('Driving mode text fit and text amount', () => { expect(m.fits || m.wordInside).toBe(true); }); }); + +test.describe('Magazine: approving a multi-page article page by page', () => { + test('keeps every block of the article and creates no Page N stand-in', async ({ page }) => { + await mockBackend(page); + 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('p3a', 3, 60), mk('p3b', 3, 120), mk('p4a', 4, 60), mk('p4b', 4, 120), mk('p5a', 5, 60)]; + const pageOf = (ids) => [...new Set(ids.map((id) => blocks.find((b) => b.id === id).page))].sort(); + let art = { id: 'article-001', title: 'Long Read', author: '', blocks: blocks.map((b) => b.id), images: [], status: 'detected', confidence: 0.9, section: '', pages: [3, 4, 5], has_audio: false }; + const patches = []; let posts = 0; + await page.route(`**/api/magazine/${DOC_ID}/layout`, r => r.fulfill({ json: { ...LAYOUT, page_count: 5, pages: [3, 4, 5].map((p) => ({ page: p, width: 595, height: 842 })), blocks } })); + await page.route(`**/api/magazine/${DOC_ID}/articles`, r => { + if (r.request().method() === 'POST') { posts++; return r.fulfill({ json: { article: { id: 'article-page-x', title: 'Page X', pages: [9], blocks: [], images: [], status: 'detected' } } }); } + return r.fulfill({ json: { articles: [art], progress: {}, default_voice: '', podcast: { hosts: [] } } }); + }); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, r => { + if (r.request().method() === 'PATCH') { + const body = r.request().postDataJSON(); patches.push(body); + art = { ...art, ...body, pages: pageOf(body.blocks) }; + return r.fulfill({ json: art }); + } + return r.fulfill({ json: art }); + }); + 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.getByRole('button', { name: /^Review/ }).click(); + await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3'); + await page.getByRole('button', { name: 'Approve & Next', exact: true }).click(); + await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4'); + await page.getByRole('button', { name: 'Approve & Next', exact: true }).click(); + await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 5'); + expect(patches).toHaveLength(2); + for (const p of patches) expect([...p.blocks].sort()).toEqual(blocks.map((b) => b.id).sort()); + expect(posts).toBe(0); + }); +});