feat(reader): surface brightness on desktop, not just the mobile sheet
All checks were successful
deploy / test-backend (pull_request) Successful in 27m40s
deploy / test-frontend (pull_request) Successful in 10m19s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped

The brightness slider lived only in the mobile reader settings sheet, so
desktop users couldn't dim — and could be left stuck dimmed by a value a
phone had persisted (brightness is shared via localStorage and the
--reader-dim effect is form-factor agnostic). Add an inline brightness
slider to the desktop reader nav, marked `desktop-control` alongside the
existing mode toggle and gap select. It binds the same `brightness` state,
so a value set on either form factor stays in sync.

Also mocks `/me/read-progress` in the reader-mode e2e setup — without it the
GET/PUT fell through to the (absent) backend and stalled the reader load,
which is why those specs couldn't run headless. The spec is now hermetic and
covers the new desktop control (it dims/undims, and a phone-persisted value
is recoverable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 20:01:05 +02:00
parent dee53fa212
commit f83ac67676
5 changed files with 107 additions and 3 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.88.0" version = "0.89.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.88.0" version = "0.89.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -66,6 +66,15 @@ async function mockReaderApis(page: Page) {
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } }) body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
}) })
); );
// Anonymous reader: read-progress reads/writes are unauthenticated. Without
// this the GET/PUT fall through to the (absent) backend and stall the load.
await page.route('**/api/v1/me/read-progress/**', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) => await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
@@ -126,6 +135,7 @@ test.beforeEach(async ({ context }) => {
try { try {
localStorage.removeItem('mangalord-reader-mode'); localStorage.removeItem('mangalord-reader-mode');
localStorage.removeItem('mangalord-reader-gap'); localStorage.removeItem('mangalord-reader-gap');
localStorage.removeItem('mangalord-reader-brightness');
} catch { } catch {
// ignore — about:blank doesn't expose localStorage yet. // ignore — about:blank doesn't expose localStorage yet.
} }
@@ -181,6 +191,64 @@ test('gap select updates the inline gap on the continuous container', async ({ p
await expect(container).toHaveAttribute('style', /gap:\s*64px/); await expect(container).toHaveAttribute('style', /gap:\s*64px/);
}); });
test('desktop reader nav exposes a brightness control that dims and undims the reader', async ({
page
}) => {
// The brightness slider used to live only in the mobile settings sheet,
// so desktop users couldn't dim — and could be stuck dimmed by a value a
// phone had persisted. The desktop nav now carries its own slider.
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
const slider = page.getByTestId('reader-brightness-desktop');
await expect(slider).toBeVisible();
const dim = () =>
page.evaluate(() =>
document.documentElement.style.getPropertyValue('--reader-dim')
);
// Default brightness is full → no dim overlay.
await expect.poll(async () => Number(await dim())).toBe(0);
// Dimming raises the overlay opacity…
await slider.fill('0.3');
await expect.poll(async () => Number(await dim())).toBeGreaterThan(0);
// …and restoring it clears the dim, so a desktop user is never stuck.
await slider.fill('1');
await expect.poll(async () => Number(await dim())).toBe(0);
});
test('a brightness value persisted by a phone is recoverable on desktop', async ({
page,
context
}) => {
// Seed a dimmed value as if set on mobile, then open the reader on a
// desktop viewport: the dim applies and the desktop control reflects it.
await context.addInitScript(() => {
try {
localStorage.setItem('mangalord-reader-brightness', '0.3');
} catch {
// about:blank — ignore
}
});
await mockReaderApis(page);
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
const slider = page.getByTestId('reader-brightness-desktop');
await expect(slider).toHaveValue('0.3');
await expect
.poll(async () =>
Number(
await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--reader-dim')
)
)
)
.toBeGreaterThan(0);
});
test('reader-mode preference set on one page is honored when the reader opens', async ({ test('reader-mode preference set on one page is honored when the reader opens', async ({
page, page,
context context

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.88.0", "version": "0.89.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1094,6 +1094,28 @@
{/if} {/if}
<!-- Desktop brightness. On mobile this control lives in the reader
settings sheet; desktop has no sheet, so it sits inline here.
Shares the `brightness` state and the same `--reader-dim`
effect, so a value set on either form factor stays in sync. -->
<label class="brightness-field desktop-control">
<Sun size={16} aria-hidden="true" />
<span class="visually-hidden">Brightness</span>
<input
type="range"
class="brightness-slider"
min="0.3"
max="1"
step="0.05"
value={brightness}
oninput={(e) => {
brightness = Number((e.currentTarget as HTMLInputElement).value);
}}
aria-label="Brightness"
data-testid="reader-brightness-desktop"
/>
</label>
<button <button
type="button" type="button"
class="reader-settings-btn mobile-control" class="reader-settings-btn mobile-control"
@@ -2181,6 +2203,20 @@
width: 100%; width: 100%;
} }
/* Inline desktop brightness control in the reader nav. The slider is
full-width inside the mobile settings sheet; here it's capped so it
sits neatly beside the mode toggle. */
.brightness-field {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
}
.brightness-field .brightness-slider {
width: 120px;
}
@media (max-width: 640px) { @media (max-width: 640px) {
/* Reader-nav sits at the viewport top — the global mobile /* Reader-nav sits at the viewport top — the global mobile
chrome is hidden on this route so there's no app bar above chrome is hidden on this route so there's no app bar above