From 605fa5b9d86da74c4b18476e9890664b941ca95d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 12 Oct 2026 23:49:02 +0200 Subject: [PATCH] Add per-user page tone modes to reader --- README.md | 1 + public/app.css | 6 ++++++ public/read.html | 1 + public/reader.js | 31 +++++++++++++++++++++++++++++++ public/settings.js | 9 +++++++++ server.js | 3 ++- test/services.test.js | 4 ++++ tools/test-reading-ui.js | 31 ++++++++++++++++++++++++++++++- 8 files changed, 84 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index df7d0ac..c3bf73b 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ whole-series completeness. See the scraper service and library format contracts - **Which mode opens:** Settings → *Open chapters in* = a fixed mode or *the mode I used last*. With *Use the fitting mode…* on, content that isn't made for your choice opens in the mode that fits (webtoons always open in Flow). That automatic switch never changes your remembered mode. Only clicking a mode in the reader does. - **Page zoom:** 100 % means the page fits the screen (in Flow: a normal single page fits the screen; for webtoons, the strip is as wide as the screen but never wider than its own width). Pages may end up larger than the screen, exactly fitting, or smaller. The toolbar −/+ buttons and keyboard `-`/`+` move in 5-percentage-point steps from 20 % through 600 %; `0` or the percentage button returns to 100 %. Ctrl + mouse wheel, touchpad pinch and two-finger pinch remain continuous for still finer control. This zooms only the pages; zooming over the toolbar zooms the whole page as usual. When a page is larger than the screen, drag it with the mouse (hand cursor) or pan with a finger. Flow locks horizontal movement while the content fits within the viewport, then enables it automatically when zoom makes the content wider. The zoom is remembered per mode for your account. - **Flow page gap:** Settings → Reading lets each user keep the normal separator between Flow pages or remove it for continuous webtoon artwork and text that crosses image edges. +- **Page tone:** the reader's **Tone** button cycles Original → Inverted → Sepia → Dim → Extra dim in Flow, Page and Book. It changes only the artwork, never the controls or site theme. The choice is saved per user; it can also be selected in Settings → User → Reading. - **Active Reading Clock:** opt in under Settings → User → Reading time & privacy. No scrolling-speed limit; a visit counts at 60% viewed-page coverage and saves on exit. Pauses in the background or after 30 seconds without reader activity. Tap the clock for this visit's time, coverage, qualification and loading wait. - **Your Reading Story:** private active-time summaries appear on the library and manga pages when history exists. Chapter rows show the last timed read (60%-coverage visit), rereads remain visible as lifetime time, and Settings lets each user hide, export, or delete their own history. No leaderboards or speed ranking are used. - **Tap / click zones:** diff --git a/public/app.css b/public/app.css index bb8acf1..3985785 100644 --- a/public/app.css +++ b/public/app.css @@ -430,3 +430,9 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } .clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); } } +#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); } +#canvas[data-page-tone="sepia"] .pg img { filter: sepia(.8) brightness(.9); } +#canvas[data-page-tone="dim"] .pg img { filter: brightness(.7); } +#canvas[data-page-tone="extra-dim"] .pg img { filter: brightness(.45); } +.page-tone { white-space: nowrap; } +.page-tone:not([data-page-tone="original"]) { border-color: var(--accent); } diff --git a/public/read.html b/public/read.html index 7207c97..8740e5c 100644 --- a/public/read.html +++ b/public/read.html @@ -38,6 +38,7 @@ +
diff --git a/public/reader.js b/public/reader.js index 421e25b..15ddd52 100644 --- a/public/reader.js +++ b/public/reader.js @@ -45,6 +45,36 @@ const shift = new Set(D.progress && D.progress.v === D.version ? D.progress.shift : []); const zkey = () => (mode === 'flow' && ttb ? 'strip' : mode); const zoom = () => S.zoom[zkey()] || 1; + const tones = [ + { id: 'original', label: 'Original' }, + { id: 'inverted', label: 'Inverted' }, + { id: 'sepia', label: 'Sepia' }, + { id: 'dim', label: 'Dim' }, + { id: 'extra-dim', label: 'Extra dim' }, + ]; + const toneButton = $('#page-tone'); + const toneName = id => tones.find(tone => tone.id === id)?.label || 'Original'; + function applyTone(id) { + S.pageTone = tones.some(tone => tone.id === id) ? id : 'original'; + canvas.dataset.pageTone = S.pageTone; + toneButton.dataset.pageTone = S.pageTone; + toneButton.textContent = `Tone: ${toneName(S.pageTone)}`; + toneButton.title = `Page images: ${toneName(S.pageTone)}. Click for the next tone; controls and theme stay unchanged.`; + toneButton.setAttribute('aria-label', toneButton.title); + } + applyTone(S.pageTone); + let savedTone = S.pageTone, toneSave = Promise.resolve(), toneChange = 0; + function cycleTone() { + const next = tones[(tones.findIndex(tone => tone.id === S.pageTone) + 1) % tones.length]; + applyTone(next.id); + const change = ++toneChange; + toneSave = toneSave.catch(() => {}).then(async () => { + await api('api/settings', { pageTone: next.id }); + savedTone = next.id; + }).catch(() => { + if (change === toneChange) { applyTone(savedTone); toast('Could not save page tone'); } + }); + } const pr = D.progress; if (pr && !(pr.done && pr.p >= pr.n - 1)) { @@ -571,6 +601,7 @@ ver.hidden = D.versions.length < 2; ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); }; for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); }; + toneButton.onclick = cycleTone; $('#shift').onclick = toggleShift; $('#zoomout').onclick = () => stepZoom(-1); $('#zoom').onclick = () => setZoom(1); diff --git a/public/settings.js b/public/settings.js index ae02606..fa29b81 100644 --- a/public/settings.js +++ b/public/settings.js @@ -31,6 +31,13 @@ h('option', { value: 'page' }, 'Always Page (one page at a time)'), h('option', { value: 'book' }, 'Always Book (two pages side by side)')); startMode.value = S.startMode; + const tone = h('select', { onchange: e => save({ pageTone: e.target.value }) }, + h('option', { value: 'original' }, 'Original'), + h('option', { value: 'inverted' }, 'Inverted'), + h('option', { value: 'sepia' }, 'Sepia'), + h('option', { value: 'dim' }, 'Dim (70% brightness)'), + h('option', { value: 'extra-dim' }, 'Extra dim (45% brightness)')); + tone.value = S.pageTone; const LABEL = { flow: 'Flow', page: 'Page', book: 'Book', strip: 'Webtoon flow' }; const zooms = () => Object.keys(LABEL).map(k => `${LABEL[k]} ${Math.round((S.zoom[k] || 1) * 100)}%`).join(' · '); const zoomText = h('span', { class: 'muted' }, zooms()); @@ -72,6 +79,8 @@ check('skipCovers', 'Skip chapter cover pages'), check('flowPageGap', 'Show a gap between pages in Flow mode', 'Turn this off for continuous webtoon artwork or text that crosses page edges.'), + h('label', { class: 'field' }, h('span', {}, 'Page tone'), tone, + h('div', { class: 'muted small' }, 'The reader button cycles through these choices. Only page artwork changes; the site theme and controls stay as they are.')), h('div', { class: 'field' }, h('span', {}, 'Page zoom'), zoomText, ' ', h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'), h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.')))); diff --git a/server.js b/server.js index c156cd3..56b7b52 100644 --- a/server.js +++ b/server.js @@ -78,7 +78,7 @@ function checkPw(pw, stored) { return want.length === got.length && crypto.timingSafeEqual(want, got); } const DUMMY_HASH = hashPw('x'); -const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, readingClock: false, readingStory: true, zoom: {}, webhook: '' }; +const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true, zoom: {}, webhook: '' }; const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } }); function createUser(name, pw, admin, replace) { @@ -363,6 +363,7 @@ const routes = { const s = (u.settings = u.settings || {}); if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode; if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode; + if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone; for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory']) if (typeof b[k] === 'boolean') s[k] = b[k]; if (b.zoom && typeof b.zoom === 'object') { s.zoom = {}; diff --git a/test/services.test.js b/test/services.test.js index 23d40e6..3bd3fd5 100644 --- a/test/services.test.js +++ b/test/services.test.js @@ -64,6 +64,10 @@ test('three services integrate collections, auth, custom covers and private heal assert.equal(anotherLogin.status, 200); const anotherCookie = anotherLogin.headers.get('set-cookie').split(';')[0]; const anotherRequest = (endpoint, body) => fetch(base + endpoint, { headers: { cookie: anotherCookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) }); + assert.equal((await (await request('/api/me')).json()).settings.pageTone, 'original'); + assert.equal((await (await request('/api/settings', { pageTone: 'sepia' })).json()).settings.pageTone, 'sepia'); + assert.equal((await (await request('/api/settings', { pageTone: 'invalid' })).json()).settings.pageTone, 'sepia'); + assert.equal((await (await anotherRequest('/api/me')).json()).settings.pageTone, 'original'); assert.equal((await (await anotherRequest('/api/greatest')).json()).entries[0].myRating, null); assert.equal((await anotherRequest('/api/greatest/rating', { id: firstPick.id, rating: 'good' })).status, 200); const communityPick = (await (await request('/api/greatest')).json()).entries[0]; diff --git a/tools/test-reading-ui.js b/tools/test-reading-ui.js index 942094f..a2ba1ff 100644 --- a/tools/test-reading-ui.js +++ b/tools/test-reading-ui.js @@ -81,7 +81,36 @@ module.exports = async function (url, cookie) { assert.equal(await page.locator('#readclock .clock-time').textContent(), pausedTime, 'Idle time still accrued'); await page.mouse.move(20, 200); await page.waitForFunction(() => document.querySelector('#readclock .clock-label')?.textContent.includes('Reading')); + await load(); + const background = await page.locator('body').evaluate(body => getComputedStyle(body).backgroundColor); + const toneButton = page.locator('#page-tone'); + await toneButton.click(); + for (const viewMode of ['flow', 'page', 'book']) { + await page.locator(`#modes button[data-mode="${viewMode}"]`).click(); + const appearance = await page.evaluate(() => ({ + tone: document.querySelector('#canvas').dataset.pageTone, + image: getComputedStyle(document.querySelector('#canvas .pg img')).filter, + controls: getComputedStyle(document.querySelector('#bot')).filter, + background: getComputedStyle(document.body).backgroundColor, + })); + assert.equal(appearance.tone, 'inverted'); + assert.match(appearance.image, /invert\(1\)/); + assert.equal(appearance.controls, 'none'); + assert.equal(appearance.background, background); + } + for (const [tone, filter] of [['sepia', /sepia/], ['dim', /brightness\(0\.7\)/], ['extra-dim', /brightness\(0\.45\)/]]) { + await toneButton.click(); + const appearance = await page.evaluate(() => ({ tone: document.querySelector('#canvas').dataset.pageTone, + filter: getComputedStyle(document.querySelector('#canvas .pg img')).filter })); + assert.equal(appearance.tone, tone); + assert.match(appearance.filter, filter); + } + await page.waitForFunction(async () => (await (await fetch('api/me')).json()).settings.pageTone === 'extra-dim'); + await load(); + assert.equal(await page.locator('#canvas').getAttribute('data-page-tone'), 'extra-dim'); + await toneButton.click(); + await page.waitForFunction(async () => (await (await fetch('api/me')).json()).settings.pageTone === 'original'); assert.equal(errors.length, 0, errors.join('\n')); - console.log('PASS: mobile flow reader fast coverage, 60% exit save with pulse in flight, lookup exclusion, bounded pulse frequency, 30-second idle pause and input resume'); + console.log('PASS: mobile reading clock and five page tones across Flow, Page and Book without changing the theme'); } finally { await context.close(); await browser.close(); } };