fix(magazine): keep the Order by clicking controls on screen in a floating panel

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-30 10:45:48 +02:00
parent 268ed391a3
commit e241821325
5 changed files with 117 additions and 7 deletions

View File

@ -34,6 +34,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- A page either side marks as a full-page advertisement is now excluded from every difference statistic and the worst-pages list, reported separately instead (`pages_skipped_as_advertisement`). Being marked an ad is a one-time manual step a fresh re-import has usually not had yet, so without this a page the baseline skips entirely (near-empty) was compared against the same page fully OCR'd in the new import -- a difference in review state, not pipeline quality, that was skewing the whole-issue numbers. - A page either side marks as a full-page advertisement is now excluded from every difference statistic and the worst-pages list, reported separately instead (`pages_skipped_as_advertisement`). Being marked an ad is a one-time manual step a fresh re-import has usually not had yet, so without this a page the baseline skips entirely (near-empty) was compared against the same page fully OCR'd in the new import -- a difference in review state, not pipeline quality, that was skewing the whole-issue numbers.
### Changed ### Changed
- **"Order by clicking" now keeps its controls on screen.** *Save order*, *Undo last* and *Cancel* lived in the toolbar row and could scroll out of reach, leaving the task unfinishable. While ordering they are a floating panel pinned to the bottom-right of the window (full width on a phone): a progress meter, a large *Save order* button that turns green when every box is numbered, *Undo last*, and *Cancel* (also **Esc**, except while typing in a field). It sits above the Back/Approve bar and clear of the page image. Tests scroll the real scroll container, and were verified to fail against the old layout.
- Spelling agent and acronym resolver call the language model with thinking off (about 4 s instead of about 27 s per call) and retry without the option if the server rejects it. - Spelling agent and acronym resolver call the language model with thinking off (about 4 s instead of about 27 s per call) and retry without the option if the server rejects it.
- The spelling agent treats run-together words (`dasich`) as errors and may split them at a space when both halves are real words and the letters are unchanged. - The spelling agent treats run-together words (`dasich`) as errors and may split them at a space when both halves are real words and the letters are unchanged.
- **A fresh import now finishes fully worked, not just laid out.** Ingestion runs the whole-issue Auto-fix pass (find missed text, re-read suspicious OCR, fix provable misspellings, link continuation pages, audit box coverage) itself as its last step, under the same job the ingest progress bar already follows -- no separate "Auto-fix all pages" click needed first. An Auto-fix failure is reported as an ingest failure (with the reason) rather than a silent gap; the ingest's own result (pages, articles) is kept either way. - **A fresh import now finishes fully worked, not just laid out.** Ingestion runs the whole-issue Auto-fix pass (find missed text, re-read suspicious OCR, fix provable misspellings, link continuation pages, audit box coverage) itself as its last step, under the same job the ingest progress bar already follows -- no separate "Auto-fix all pages" click needed first. An Auto-fix failure is reported as an ingest failure (with the reason) rather than a silent gap; the ingest's own result (pages, articles) is kept either way.

View File

@ -253,6 +253,7 @@ turned into a podcast discussion between two or three voices you pick.
**Text** / **Images** toggles that show or hide the article text as karaoke (the whole article scrolls, the sentence being read is marked and the current word is highlighted; word timing is estimated from the MP3 position) and the article's current picture. Everything is a **Text** / **Images** toggles that show or hide the article text as karaoke (the whole article scrolls, the sentence being read is marked and the current word is highlighted; word timing is estimated from the MP3 position) and the article's current picture. Everything is a
large touch target and keyboard reachable; the speed also applies to the large touch target and keyboard reachable; the speed also applies to the
playlist player. playlist player.
While you order, a panel pinned to the bottom-right of the window shows how many boxes are numbered and holds **Save order**, **Undo last** and **Cancel** (or press **Esc**). It stays in view however far you scroll, sits above the Back / Approve bar and clear of the page image, and turns green when every box is numbered. **Save order** is only enabled once you have clicked at least one box; on a phone the panel spans the width of the screen.
**Expected result:** the article plays from its headline to its final paragraph. **Expected result:** the article plays from its headline to its final paragraph.
Page furniture (running heads, page numbers), adverts, sidebars and pull quotes Page furniture (running heads, page numbers), adverts, sidebars and pull quotes

View File

@ -1853,7 +1853,7 @@ function renderReview() {
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' + '<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'</div>' + '</div>' +
'<span id="mag-draw-hint" class="muted"></span>' + '<span id="mag-draw-hint" class="muted"></span>' +
'<div id="mag-order-bar" class="mag-order-bar" role="status" aria-live="polite">' + magOrderBarHtml() + '</div>' + '<div id="mag-order-bar" class="mag-order-bar' + (state.orderMode ? ' is-floating' : '') + '" role="region" aria-label="Order by clicking" aria-live="polite">' + magOrderBarHtml() + '</div>' +
'<div id="mag-select-bar" class="mag-order-bar" role="status" aria-live="polite">' + magSelectBarHtml() + '</div>' + '<div id="mag-select-bar" class="mag-order-bar" role="status" aria-live="polite">' + magSelectBarHtml() + '</div>' +
'<div id="mag-undo-bar" class="mag-order-bar" role="status" aria-live="polite">' + magUndoBarHtml() + '</div>'; '<div id="mag-undo-bar" class="mag-order-bar" role="status" aria-live="polite">' + magUndoBarHtml() + '</div>';
html += '</div>'; html += '</div>';
@ -2139,12 +2139,23 @@ function magReviewPageLocked() {
function magOrderBarHtml() { function magOrderBarHtml() {
if (state.orderMode) { if (state.orderMode) {
// A floating panel (see .mag-order-bar.is-floating): pinned to the viewport so the button that finishes
// the task can never scroll out of sight, yet parked in a corner clear of the page image being clicked.
const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length; const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length;
return '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' + const all = total > 0 && done >= total;
'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' + return '<div class="mag-order-head"><span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'<button type="button" class="btn-sm btn-primary" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>Save order</button> ' + '<strong>Order by clicking</strong></div>' +
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button> ' + '<p class="mag-order-hint">' + (all
'<button type="button" class="btn-sm" onclick="magOrderToggle()">Cancel</button>'; ? 'Every box is numbered. Press <strong>Save order</strong> to keep it.'
: 'Click the boxes on the page in the order they are read. Boxes you skip keep their current order.') + '</p>' +
'<div class="mag-order-progress"><progress max="' + Math.max(total, 1) + '" value="' + done + '" aria-label="Boxes numbered"></progress>' +
'<span>' + done + ' of ' + total + ' set</span></div>' +
'<div class="mag-order-actions">' +
'<button type="button" class="btn-primary mag-order-save' + (all ? ' is-ready' : '') + '" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>' +
'<span class="mdi mdi-content-save-outline" aria-hidden="true"></span> Save order</button>' +
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button>' +
'<button type="button" class="btn-sm" onclick="magOrderToggle()" title="Discard this numbering (Esc)">Cancel</button>' +
'</div>';
} }
if (magReviewPageLocked()) { if (magReviewPageLocked()) {
return '<span class="mdi mdi-lock-outline" aria-hidden="true"></span> Reading order on this page was set by hand and is kept. ' + return '<span class="mdi mdi-lock-outline" aria-hidden="true"></span> Reading order on this page was set by hand and is kept. ' +
@ -2155,7 +2166,7 @@ function magOrderBarHtml() {
function magOrderRefreshBar() { function magOrderRefreshBar() {
const bar = $('mag-order-bar'); const bar = $('mag-order-bar');
if (bar) bar.innerHTML = magOrderBarHtml(); if (bar) { bar.innerHTML = magOrderBarHtml(); bar.classList.toggle('is-floating', !!state.orderMode); }
document.querySelectorAll('#mag-review-page-wrap .mag-review-box').forEach((box) => { document.querySelectorAll('#mag-review-page-wrap .mag-review-box').forEach((box) => {
const at = (state.orderSeq || []).indexOf(box.dataset.block); const at = (state.orderSeq || []).indexOf(box.dataset.block);
box.classList.toggle('is-ordered', state.orderMode && at >= 0); box.classList.toggle('is-ordered', state.orderMode && at >= 0);
@ -2170,6 +2181,8 @@ function magOrderToggle() {
state.orderPage = state.reviewPage; state.orderPage = state.reviewPage;
if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind); if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind);
if (state.orderMode && state.editMode) magReviewToggleEdit(); if (state.orderMode && state.editMode) magReviewToggleEdit();
document.removeEventListener('keydown', magOrderKey);
if (state.orderMode) document.addEventListener('keydown', magOrderKey);
const btn = $('mag-order-toggle'); const btn = $('mag-order-toggle');
if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); } if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); }
const wrap = $('mag-review-page-wrap'); const wrap = $('mag-review-page-wrap');
@ -2178,6 +2191,14 @@ function magOrderToggle() {
magOrderRefreshBar(); magOrderRefreshBar();
} }
// Esc discards the numbering, like Cancel -- unless the key is meant for a field (a position box, a select).
function magOrderKey(e) {
if (e.key !== 'Escape' || !state.orderMode) return;
if (e.target && e.target.closest && e.target.closest('input, textarea, select')) return;
e.preventDefault();
magOrderToggle();
}
function magOrderPick(id) { function magOrderPick(id) {
const seq = state.orderSeq || (state.orderSeq = []); const seq = state.orderSeq || (state.orderSeq = []);
if (!seq.includes(id)) seq.push(id); if (!seq.includes(id)) seq.push(id);

View File

@ -5573,3 +5573,28 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
background: var(--surface); border-radius: 0 var(--radius) var(--radius) 0; color: var(--subtext); font-size: .92em; } background: var(--surface); border-radius: 0 var(--radius) var(--radius) 0; color: var(--subtext); font-size: .92em; }
.mag-aside-label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; } .mag-aside-label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.mag-aside-text { display: block; white-space: pre-line; overflow-wrap: anywhere; } .mag-aside-text { display: block; white-space: pre-line; overflow-wrap: anywhere; }
/* Order by clicking: a floating panel, so "Save order" is always on screen and never inside the way */
.mag-order-bar.is-floating { position: fixed; right: 16px; bottom: calc(76px + env(safe-area-inset-bottom)); z-index: 60; /* 76px clears the sticky Back/Approve bar */
flex-direction: column; align-items: stretch; gap: 8px; width: min(340px, calc(100vw - 32px)); margin: 0; padding: 14px;
border: 2px solid var(--accent, #2563eb); border-radius: 12px; background: var(--surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, .28); font-size: 14px; }
.mag-order-head { display: flex; align-items: center; gap: 8px; font-size: 15px; }
.mag-order-hint { margin: 0; color: var(--subtext); line-height: 1.4; }
.mag-order-progress { display: flex; align-items: center; gap: 10px; font-variant-numeric: tabular-nums; }
.mag-order-progress progress { flex: 1; height: 10px; accent-color: var(--accent, #2563eb); }
.mag-order-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.mag-order-save { flex: 1 1 100%; min-height: 48px; font-size: 16px; font-weight: 700; }
.mag-order-save.is-ready { background: var(--green, #16a34a); border-color: var(--green, #16a34a); color: #fff; }
.mag-order-actions .btn-sm { min-height: 40px; flex: 1 1 auto; }
.mag-order-bar.is-floating :focus-visible { outline: 3px solid var(--accent, #2563eb); outline-offset: 2px; }
@media (max-width: 700px) {
.mag-order-bar.is-floating { left: 8px; right: 8px; bottom: calc(76px + env(safe-area-inset-bottom)); width: auto; padding: 10px; gap: 6px; }
.mag-order-hint { font-size: 12px; }
.mag-order-save { min-height: 44px; }
.mag-order-actions .btn-sm { min-height: 40px; }
}
@media (prefers-reduced-motion: no-preference) {
.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; } }
}

View File

@ -1084,6 +1084,68 @@ test('order by clicking: boxes clicked on the page are saved in click order', as
await expect.poll(() => sent).toEqual(['b2', 'b1']); await expect.poll(() => sent).toEqual(['b2', 'b1']);
}); });
test('order by clicking: Save order stays on screen however far the page is scrolled', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
const save = page.getByRole('button', { name: 'Save order', exact: true });
await expect(save).toBeVisible();
await expect(save).toBeDisabled(); // nothing numbered yet
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
await expect(save).toBeEnabled();
// The app scrolls inside its own container, not the window. Find it, make the review long, and scroll that.
const scroller = await page.evaluateHandle(() => {
let el = document.getElementById('mag-review');
while (el && el !== document.documentElement) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight - 1) return el;
el = el.parentElement;
}
return document.scrollingElement;
});
await scroller.evaluate((el) => { const f = document.createElement('div'); f.style.height = '6000px'; f.id = 'test-filler'; (el === document.scrollingElement ? document.body : el).appendChild(f); });
const scrollBy = async (top) => scroller.evaluate((el, y) => { el.scrollTo(0, y); return el.scrollTop; }, top);
expect(await scrollBy(4000)).toBeGreaterThan(1000); // proves the container really scrolled
for (const y of [0, 1500, 4000, 6500]) {
await scrollBy(y);
await expect(save).toBeInViewport({ ratio: 1 });
}
// It floats in a corner: a proper touch target, inside the viewport, clear of the toolbar.
const box = await save.boundingBox();
const vp = page.viewportSize();
expect(box.y + box.height).toBeLessThanOrEqual(vp.height);
expect(box.height).toBeGreaterThanOrEqual(44);
await scrollBy(0);
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toBeInViewport();
});
test('order by clicking: progress, a ready state when every box is numbered, and Esc to cancel', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
const bar = page.locator('#mag-order-bar');
await expect(bar).toHaveClass(/is-floating/);
await expect(bar.locator('progress')).toHaveAttribute('value', '0');
for (const id of ['b1', 'b2', 'b3']) await page.locator(`#mag-review-page-wrap [data-block="${id}"]`).click();
await expect(bar.locator('progress')).toHaveAttribute('value', '3');
await expect(bar).toContainText('Every box is numbered');
await expect(page.getByRole('button', { name: 'Save order', exact: true })).toHaveClass(/is-ready/);
await page.keyboard.press('Escape');
await expect(bar).not.toHaveClass(/is-floating/);
await expect(bar).toBeEmpty();
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toHaveAttribute('aria-pressed', 'false');
});
test('order by clicking: Esc inside a position field does not cancel the numbering', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
await page.getByRole('spinbutton', { name: /Reading position of region 2/ }).focus();
await page.keyboard.press('Escape');
await expect(page.locator('#mag-order-bar')).toHaveClass(/is-floating/);
});
test('a hand-ordered page says so and can go back to the automatic order', async ({ page }) => { test('a hand-ordered page says so and can go back to the automatic order', async ({ page }) => {
await mockBackend(page); await mockBackend(page);
let reset = false; let reset = false;