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

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
large touch target and keyboard reachable; the speed also applies to the
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.
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>' +
'</div>' +
'<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-undo-bar" class="mag-order-bar" role="status" aria-live="polite">' + magUndoBarHtml() + '</div>';
html += '</div>';
@ -2139,12 +2139,23 @@ function magReviewPageLocked() {
function magOrderBarHtml() {
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;
return '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' +
'<button type="button" class="btn-sm btn-primary" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>Save order</button> ' +
const all = total > 0 && done >= total;
return '<div class="mag-order-head"><span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'<strong>Order by clicking</strong></div>' +
'<p class="mag-order-hint">' + (all
? '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()">Cancel</button>';
'<button type="button" class="btn-sm" onclick="magOrderToggle()" title="Discard this numbering (Esc)">Cancel</button>' +
'</div>';
}
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. ' +
@ -2155,7 +2166,7 @@ function magOrderBarHtml() {
function magOrderRefreshBar() {
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) => {
const at = (state.orderSeq || []).indexOf(box.dataset.block);
box.classList.toggle('is-ordered', state.orderMode && at >= 0);
@ -2170,6 +2181,8 @@ function magOrderToggle() {
state.orderPage = state.reviewPage;
if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind);
if (state.orderMode && state.editMode) magReviewToggleEdit();
document.removeEventListener('keydown', magOrderKey);
if (state.orderMode) document.addEventListener('keydown', magOrderKey);
const btn = $('mag-order-toggle');
if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); }
const wrap = $('mag-review-page-wrap');
@ -2178,6 +2191,14 @@ function magOrderToggle() {
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) {
const seq = state.orderSeq || (state.orderSeq = []);
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; }
.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; }
/* 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']);
});
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 }) => {
await mockBackend(page);
let reset = false;