diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1134e14..e20f996 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/Manual.md b/Manual.md
index e46e594..f7fc901 100644
--- a/Manual.md
+++ b/Manual.md
@@ -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
diff --git a/static/js/magazine.js b/static/js/magazine.js
index 24343f6..769ce6b 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -1853,7 +1853,7 @@ function renderReview() {
'' +
'' +
'' +
- '
' + magOrderBarHtml() + '
' +
+ '
' + magOrderBarHtml() + '
' +
'
' + magSelectBarHtml() + '
' +
'
' + magUndoBarHtml() + '
';
html += '';
@@ -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 ' ' +
- 'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' +
- ' ' +
- ' ' +
- '';
+ const all = total > 0 && done >= total;
+ return '
' +
+ 'Order by clicking
' +
+ '
' + (all
+ ? 'Every box is numbered. Press Save order to keep it.'
+ : 'Click the boxes on the page in the order they are read. Boxes you skip keep their current order.') + '
' +
+ '
' +
+ '' + done + ' of ' + total + ' set
' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '
';
}
if (magReviewPageLocked()) {
return ' 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);
diff --git a/static/style.css b/static/style.css
index 76d2779..118543d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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; } }
+}
diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js
index 2a6c5d8..eb46744 100644
--- a/tests/magazine.spec.js
+++ b/tests/magazine.spec.js
@@ -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;