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;