fix(magazine): approving a review page keeps all blocks of a multi-page article

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-03 14:48:37 +02:00
parent 572fae892d
commit 42bb556db9
4 changed files with 43 additions and 1 deletions

View File

@ -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. - 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. - `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before.
### Fixed ### 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. - **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. - **"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`. - **A boy rendered as an adult:** `_character_age_category` read `age`, but sheets store `age_estimate`.

View File

@ -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 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 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.
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

@ -3615,8 +3615,11 @@ function magSelectionAddAlongside(existingId, newId) {
function selectedBlockIds() { function selectedBlockIds() {
const exists = new Set((state.layout?.blocks || []).map((b) => b.id)); 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) 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; return state.layout ? ids.filter((id) => exists.has(id)) : ids;
} }

View File

@ -4082,3 +4082,40 @@ test.describe('Driving mode text fit and text amount', () => {
expect(m.fits || m.wordInside).toBe(true); 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);
});
});